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:
191
app/api/curator-comment/route.ts
Normal file
191
app/api/curator-comment/route.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { rateLimit } from '@/lib/rateLimit';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// Rate limiting: 3 requests per minute
|
||||||
|
const rateLimitError = rateLimit(request, { windowMs: 60000, maxRequests: 3 });
|
||||||
|
if (rateLimitError) return rateLimitError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { puzzleId, genreId, message, playerIdentifier } = await request.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!puzzleId || !message || !playerIdentifier) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'puzzleId, message, and playerIdentifier are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message validation: max 2000 characters, no empty message
|
||||||
|
const trimmedMessage = message.trim();
|
||||||
|
if (trimmedMessage.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Message cannot be empty' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (trimmedMessage.length > 2000) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Message too long. Maximum 2000 characters allowed.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerIdentifier validation: Check if it exists in PlayerState
|
||||||
|
const playerState = await prisma.playerState.findFirst({
|
||||||
|
where: {
|
||||||
|
identifier: playerIdentifier
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!playerState) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid player identifier' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puzzle validation: Check if puzzle exists and matches genreId
|
||||||
|
const puzzle = await prisma.dailyPuzzle.findUnique({
|
||||||
|
where: { id: Number(puzzleId) },
|
||||||
|
include: {
|
||||||
|
song: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!puzzle) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Puzzle not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate genreId matches puzzle (if genreId is provided)
|
||||||
|
if (genreId !== null && genreId !== undefined) {
|
||||||
|
if (puzzle.genreId !== Number(genreId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Puzzle does not match the provided genre' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no genreId provided, use puzzle's genreId
|
||||||
|
// For global puzzles, genreId is null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit check: Check if comment already exists for this playerIdentifier + puzzleId
|
||||||
|
const existingComment = await prisma.curatorComment.findUnique({
|
||||||
|
where: {
|
||||||
|
playerIdentifier_puzzleId: {
|
||||||
|
playerIdentifier: playerIdentifier,
|
||||||
|
puzzleId: Number(puzzleId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingComment) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'You have already sent a comment for this puzzle' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine responsible curators
|
||||||
|
const finalGenreId = genreId !== null && genreId !== undefined ? Number(genreId) : puzzle.genreId;
|
||||||
|
|
||||||
|
let curatorIds: number[] = [];
|
||||||
|
|
||||||
|
if (finalGenreId === null) {
|
||||||
|
// Global puzzle: Get all global curators
|
||||||
|
const globalCurators = await prisma.curator.findMany({
|
||||||
|
where: {
|
||||||
|
isGlobalCurator: true
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
curatorIds = globalCurators.map(c => c.id);
|
||||||
|
} else {
|
||||||
|
// Genre puzzle: Get curators for this genre + all global curators
|
||||||
|
const genreCurators = await prisma.curatorGenre.findMany({
|
||||||
|
where: {
|
||||||
|
genreId: finalGenreId
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
curatorId: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const globalCurators = await prisma.curator.findMany({
|
||||||
|
where: {
|
||||||
|
isGlobalCurator: true
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine and deduplicate curator IDs
|
||||||
|
const allCuratorIds = new Set<number>();
|
||||||
|
genreCurators.forEach(cg => allCuratorIds.add(cg.curatorId));
|
||||||
|
globalCurators.forEach(gc => allCuratorIds.add(gc.id));
|
||||||
|
curatorIds = Array.from(allCuratorIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curatorIds.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No curators found for this puzzle' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create comment and recipients in a transaction
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Create the comment
|
||||||
|
const comment = await tx.curatorComment.create({
|
||||||
|
data: {
|
||||||
|
playerIdentifier: playerIdentifier,
|
||||||
|
puzzleId: Number(puzzleId),
|
||||||
|
genreId: finalGenreId,
|
||||||
|
message: trimmedMessage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create recipients for all curators
|
||||||
|
await tx.curatorCommentRecipient.createMany({
|
||||||
|
data: curatorIds.map(curatorId => ({
|
||||||
|
commentId: comment.id,
|
||||||
|
curatorId: curatorId
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
commentId: result.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating curator comment:', error);
|
||||||
|
|
||||||
|
// Handle unique constraint violation (shouldn't happen due to our check, but just in case)
|
||||||
|
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'You have already sent a comment for this puzzle' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
app/api/curator-comments/[id]/read/route.ts
Normal file
66
app/api/curator-comments/[id]/read/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
// Require curator authentication
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) {
|
||||||
|
return error!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only curators can mark comments as read
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can mark comments as read' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const commentId = Number(id);
|
||||||
|
const curatorId = context.curator.id;
|
||||||
|
|
||||||
|
// Verify that this comment belongs to this curator
|
||||||
|
const recipient = await prisma.curatorCommentRecipient.findUnique({
|
||||||
|
where: {
|
||||||
|
commentId_curatorId: {
|
||||||
|
commentId: commentId,
|
||||||
|
curatorId: curatorId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Comment not found or access denied' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update readAt timestamp
|
||||||
|
await prisma.curatorCommentRecipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
readAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking comment as read:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
88
app/api/curator-comments/route.ts
Normal file
88
app/api/curator-comments/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
// Require curator authentication
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) {
|
||||||
|
return error!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only curators can view comments (not admins directly)
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can view comments' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const curatorId = context.curator.id;
|
||||||
|
|
||||||
|
// Get all comments for this curator, ordered by creation date (newest first)
|
||||||
|
const comments = await prisma.curatorCommentRecipient.findMany({
|
||||||
|
where: {
|
||||||
|
curatorId: curatorId
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
comment: {
|
||||||
|
include: {
|
||||||
|
puzzle: {
|
||||||
|
include: {
|
||||||
|
song: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
artist: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
genre: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
comment: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format the response
|
||||||
|
const formattedComments = comments.map(recipient => ({
|
||||||
|
id: recipient.comment.id,
|
||||||
|
message: recipient.comment.message,
|
||||||
|
createdAt: recipient.comment.createdAt,
|
||||||
|
readAt: recipient.readAt,
|
||||||
|
puzzle: {
|
||||||
|
id: recipient.comment.puzzle.id,
|
||||||
|
date: recipient.comment.puzzle.date,
|
||||||
|
song: {
|
||||||
|
title: recipient.comment.puzzle.song.title,
|
||||||
|
artist: recipient.comment.puzzle.song.artist
|
||||||
|
},
|
||||||
|
genre: recipient.comment.puzzle.genre ? {
|
||||||
|
id: recipient.comment.puzzle.genre.id,
|
||||||
|
name: recipient.comment.puzzle.genre.name
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(formattedComments);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching curator comments:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -37,6 +37,25 @@ interface CuratorInfo {
|
|||||||
specialIds: number[];
|
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() {
|
function getCuratorAuthHeaders() {
|
||||||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||||||
const username = localStorage.getItem('hoerdle_curator_username') || '';
|
const username = localStorage.getItem('hoerdle_curator_username') || '';
|
||||||
@@ -99,6 +118,11 @@ export default function CuratorPageClient() {
|
|||||||
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
||||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | 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(() => {
|
useEffect(() => {
|
||||||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||||||
const storedUsername = localStorage.getItem('hoerdle_curator_username');
|
const storedUsername = localStorage.getItem('hoerdle_curator_username');
|
||||||
@@ -109,6 +133,12 @@ export default function CuratorPageClient() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showComments && isAuthenticated) {
|
||||||
|
fetchComments();
|
||||||
|
}
|
||||||
|
}, [showComments, isAuthenticated]);
|
||||||
|
|
||||||
const bootstrapCuratorData = async () => {
|
const bootstrapCuratorData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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 fetchCuratorInfo = async () => {
|
||||||
const res = await fetch('/api/curator/me', {
|
const res = await fetch('/api/curator/me', {
|
||||||
headers: getCuratorAuthHeaders(),
|
headers: getCuratorAuthHeaders(),
|
||||||
@@ -1325,6 +1391,109 @@ export default function CuratorPageClient() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { ExternalPuzzle } from '@/lib/externalPuzzles';
|
|||||||
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
|
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
|
||||||
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
|
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
|
||||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
import { getOrCreatePlayerId } from '@/lib/playerId';
|
||||||
|
|
||||||
// Plausible Analytics
|
// Plausible Analytics
|
||||||
declare global {
|
declare global {
|
||||||
@@ -60,6 +61,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||||
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||||
const audioPlayerRef = useRef<AudioPlayerRef>(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(() => {
|
useEffect(() => {
|
||||||
const updateCountdown = () => {
|
const updateCountdown = () => {
|
||||||
@@ -134,6 +139,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
} else {
|
} else {
|
||||||
setHasRated(false);
|
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]);
|
}, [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);
|
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 unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
@@ -532,6 +599,66 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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} />}
|
{statistics && <Statistics statistics={statistics} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -57,6 +57,13 @@
|
|||||||
"points": "Punkte",
|
"points": "Punkte",
|
||||||
"skipBonus": "Bonus überspringen",
|
"skipBonus": "Bonus überspringen",
|
||||||
"notQuite": "Nicht ganz!",
|
"notQuite": "Nicht ganz!",
|
||||||
|
"sendComment": "Nachricht an Kurator senden",
|
||||||
|
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres...",
|
||||||
|
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
|
||||||
|
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
|
||||||
|
"commentError": "Fehler beim Senden der Nachricht",
|
||||||
|
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
|
||||||
|
"sending": "Wird gesendet...",
|
||||||
"youGuessed": "Du hast geraten",
|
"youGuessed": "Du hast geraten",
|
||||||
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
||||||
"skipped": "Übersprungen",
|
"skipped": "Übersprungen",
|
||||||
@@ -232,7 +239,16 @@
|
|||||||
"loadingData": "Lade Daten...",
|
"loadingData": "Lade Daten...",
|
||||||
"loggedInAs": "Eingeloggt als {username}",
|
"loggedInAs": "Eingeloggt als {username}",
|
||||||
"globalCuratorSuffix": " (Globaler Kurator)",
|
"globalCuratorSuffix": " (Globaler Kurator)",
|
||||||
"pageSizeLabel": "Pro Seite:"
|
"pageSizeLabel": "Pro Seite:",
|
||||||
|
"commentsTitle": "Kommentare",
|
||||||
|
"showComments": "Kommentare anzeigen",
|
||||||
|
"hideComments": "Kommentare ausblenden",
|
||||||
|
"loadingComments": "Kommentare werden geladen...",
|
||||||
|
"noComments": "Keine Kommentare vorhanden.",
|
||||||
|
"loadCommentsError": "Fehler beim Laden der Kommentare.",
|
||||||
|
"commentFromPuzzle": "Kommentar zu Puzzle",
|
||||||
|
"commentGenre": "Genre",
|
||||||
|
"unreadComment": "Ungelesen"
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "Über Hördle & Impressum",
|
"title": "Über Hördle & Impressum",
|
||||||
|
|||||||
@@ -57,6 +57,13 @@
|
|||||||
"points": "points",
|
"points": "points",
|
||||||
"skipBonus": "Skip Bonus",
|
"skipBonus": "Skip Bonus",
|
||||||
"notQuite": "Not quite!",
|
"notQuite": "Not quite!",
|
||||||
|
"sendComment": "Send message to curator",
|
||||||
|
"commentPlaceholder": "Write a message to the curators of this genre...",
|
||||||
|
"commentHelp": "Share your thoughts about the puzzle with the curators. Your message will be displayed to them.",
|
||||||
|
"commentSent": "✓ Message sent! Thank you for your feedback.",
|
||||||
|
"commentError": "Error sending message",
|
||||||
|
"commentRateLimited": "You have already sent a message for this puzzle.",
|
||||||
|
"sending": "Sending...",
|
||||||
"youGuessed": "You guessed",
|
"youGuessed": "You guessed",
|
||||||
"actuallyReleasedIn": "Actually released in",
|
"actuallyReleasedIn": "Actually released in",
|
||||||
"skipped": "Skipped",
|
"skipped": "Skipped",
|
||||||
@@ -232,7 +239,16 @@
|
|||||||
"loadingData": "Loading data...",
|
"loadingData": "Loading data...",
|
||||||
"loggedInAs": "Logged in as {username}",
|
"loggedInAs": "Logged in as {username}",
|
||||||
"globalCuratorSuffix": " (Global curator)",
|
"globalCuratorSuffix": " (Global curator)",
|
||||||
"pageSizeLabel": "Per page:"
|
"pageSizeLabel": "Per page:",
|
||||||
|
"commentsTitle": "Comments",
|
||||||
|
"showComments": "Show comments",
|
||||||
|
"hideComments": "Hide comments",
|
||||||
|
"loadingComments": "Loading comments...",
|
||||||
|
"noComments": "No comments available.",
|
||||||
|
"loadCommentsError": "Error loading comments.",
|
||||||
|
"commentFromPuzzle": "Comment from puzzle",
|
||||||
|
"commentGenre": "Genre",
|
||||||
|
"unreadComment": "Unread"
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "About Hördle & Imprint",
|
"title": "About Hördle & Imprint",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CuratorComment" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"playerIdentifier" TEXT NOT NULL,
|
||||||
|
"puzzleId" INTEGER NOT NULL,
|
||||||
|
"genreId" INTEGER,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "CuratorComment_puzzleId_fkey" FOREIGN KEY ("puzzleId") REFERENCES "DailyPuzzle" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "CuratorComment_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CuratorCommentRecipient" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"commentId" INTEGER NOT NULL,
|
||||||
|
"curatorId" INTEGER NOT NULL,
|
||||||
|
"readAt" DATETIME,
|
||||||
|
CONSTRAINT "CuratorCommentRecipient_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "CuratorComment" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "CuratorCommentRecipient_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CuratorComment_playerIdentifier_puzzleId_key" ON "CuratorComment"("playerIdentifier", "puzzleId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CuratorComment_genreId_idx" ON "CuratorComment"("genreId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CuratorCommentRecipient_commentId_curatorId_key" ON "CuratorCommentRecipient"("commentId", "curatorId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CuratorCommentRecipient_curatorId_idx" ON "CuratorCommentRecipient"("curatorId");
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ model Genre {
|
|||||||
songs Song[]
|
songs Song[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
curatorGenres CuratorGenre[]
|
curatorGenres CuratorGenre[]
|
||||||
|
comments CuratorComment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Special {
|
model Special {
|
||||||
@@ -73,6 +74,7 @@ model DailyPuzzle {
|
|||||||
genre Genre? @relation(fields: [genreId], references: [id])
|
genre Genre? @relation(fields: [genreId], references: [id])
|
||||||
specialId Int?
|
specialId Int?
|
||||||
special Special? @relation(fields: [specialId], references: [id])
|
special Special? @relation(fields: [specialId], references: [id])
|
||||||
|
comments CuratorComment[]
|
||||||
|
|
||||||
@@unique([date, genreId, specialId])
|
@@unique([date, genreId, specialId])
|
||||||
}
|
}
|
||||||
@@ -114,6 +116,7 @@ model Curator {
|
|||||||
|
|
||||||
genres CuratorGenre[]
|
genres CuratorGenre[]
|
||||||
specials CuratorSpecial[]
|
specials CuratorSpecial[]
|
||||||
|
commentRecipients CuratorCommentRecipient[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CuratorGenre {
|
model CuratorGenre {
|
||||||
@@ -149,3 +152,31 @@ model PoliticalStatement {
|
|||||||
|
|
||||||
@@index([locale, active])
|
@@index([locale, active])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CuratorComment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
playerIdentifier String
|
||||||
|
puzzleId Int
|
||||||
|
puzzle DailyPuzzle @relation(fields: [puzzleId], references: [id], onDelete: Cascade)
|
||||||
|
genreId Int?
|
||||||
|
genre Genre? @relation(fields: [genreId], references: [id])
|
||||||
|
message String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
recipients CuratorCommentRecipient[]
|
||||||
|
|
||||||
|
@@unique([playerIdentifier, puzzleId])
|
||||||
|
@@index([genreId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CuratorCommentRecipient {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
commentId Int
|
||||||
|
comment CuratorComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||||
|
curatorId Int
|
||||||
|
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
|
||||||
|
readAt DateTime?
|
||||||
|
|
||||||
|
@@unique([commentId, curatorId])
|
||||||
|
@@index([curatorId])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user