Compare commits

...

7 Commits

Author SHA1 Message Date
Hördle Bot
d2548c2870 Bump version to 0.1.6.0 2025-12-03 23:17:56 +01:00
Hördle Bot
40d6ea75f0 Behebe zwei Bugs im Kurator-Kommentar-System
Bug 1: Special-Kuratoren für Special-Puzzles berücksichtigen
- Kommentare zu Special-Puzzles werden jetzt korrekt an Special-Kuratoren geroutet
- Logik erweitert: Prüft zuerst puzzle.specialId, dann genreId, dann global
- Special-Kuratoren werden über CuratorSpecial abgerufen

Bug 2: Prisma Schema konsistent mit Migration
- onDelete: SetNull zur genre-Relation in CuratorComment hinzugefügt
- Entspricht jetzt dem Foreign Key Constraint in der Migration
2025-12-03 23:15:18 +01:00
Hördle Bot
0054facbe7 Füge Puzzle-Kontext zu Kommentaren hinzu und verbessere Sichtbarkeit neuer Kommentare
- Puzzle-Kontext: Hördle #, Genre/Special, Titel werden jetzt angezeigt
- API erweitert: puzzleNumber wird berechnet, special-Informationen inkludiert
- Badge für neue Kommentare: zeigt Anzahl ungelesener Kommentare
- Verbesserte Kommentar-Anzeige mit vollständigem Rätsel-Kontext
- UI-Anpassungen: nur Badge für neue Kommentare, keine übermäßige Hervorhebung
2025-12-03 23:09:45 +01:00
Hördle Bot
95bcf9ed1e Füge Archivierungs-Funktion für Kommentare hinzu und fixe initiales Laden
- Archivierungs-Funktionalität: Kuratoren können Kommentare archivieren
- archived-Flag in CuratorCommentRecipient hinzugefügt
- API-Route für Archivieren: /api/curator-comments/[id]/archive
- Kommentare werden beim initialen Laden automatisch abgerufen
- Archivierte Kommentare werden nicht mehr in der Liste angezeigt
- Archivieren-Button in der UI hinzugefügt
- Migration für archived-Feld
- Übersetzungen für Archivierung (DE/EN)
2025-12-03 22:57:28 +01:00
Hördle Bot
08fedf9881 Verschiebe Kommentare-Sektion ganz nach oben in Kuratoren-Seite 2025-12-03 22:47:40 +01:00
Hördle Bot
cd564b5d8c 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
2025-12-03 22:46:02 +01:00
Hördle Bot
863539a5e9 Add Restic backup to remote repository in deploy script 2025-12-03 19:19:11 +01:00
14 changed files with 1032 additions and 5 deletions

View File

@@ -0,0 +1,194 @@
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;
const specialId = puzzle.specialId;
let curatorIds: number[] = [];
const allCuratorIds = new Set<number>();
// Get all global curators (always included)
const globalCurators = await prisma.curator.findMany({
where: {
isGlobalCurator: true
},
select: {
id: true
}
});
globalCurators.forEach(gc => allCuratorIds.add(gc.id));
// Check for special puzzle first (takes precedence)
if (specialId !== null) {
// Special puzzle: Get curators for this special + all global curators
const specialCurators = await prisma.curatorSpecial.findMany({
where: {
specialId: specialId
},
select: {
curatorId: true
}
});
specialCurators.forEach(cs => allCuratorIds.add(cs.curatorId));
} else if (finalGenreId !== null) {
// Genre puzzle: Get curators for this genre + all global curators
const genreCurators = await prisma.curatorGenre.findMany({
where: {
genreId: finalGenreId
},
select: {
curatorId: true
}
});
genreCurators.forEach(cg => allCuratorIds.add(cg.curatorId));
}
// else: Global puzzle - only global curators (already added above)
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 }
);
}
}

View 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 archive comments
if (context.role !== 'curator') {
return NextResponse.json(
{ error: 'Only curators can archive comments' },
{ 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 archived flag
await prisma.curatorCommentRecipient.update({
where: {
id: recipient.id
},
data: {
archived: true
}
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error archiving comment:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View 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 }
);
}
}

View File

@@ -0,0 +1,139 @@
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 non-archived comments for this curator, ordered by creation date (newest first)
const comments = await prisma.curatorCommentRecipient.findMany({
where: {
curatorId: curatorId,
archived: false
},
include: {
comment: {
include: {
puzzle: {
include: {
song: {
select: {
title: true,
artist: true
}
},
genre: {
select: {
id: true,
name: true
}
},
special: {
select: {
id: true,
name: true
}
}
}
}
}
}
},
orderBy: {
comment: {
createdAt: 'desc'
}
}
});
// Format the response with puzzle context
const formattedComments = await Promise.all(comments.map(async (recipient) => {
const puzzle = recipient.comment.puzzle;
// Calculate puzzle number
let puzzleNumber = 0;
if (puzzle.specialId) {
// Special puzzle
puzzleNumber = await prisma.dailyPuzzle.count({
where: {
specialId: puzzle.specialId,
date: {
lte: puzzle.date
}
}
});
} else if (puzzle.genreId) {
// Genre puzzle
puzzleNumber = await prisma.dailyPuzzle.count({
where: {
genreId: puzzle.genreId,
date: {
lte: puzzle.date
}
}
});
} else {
// Global puzzle
puzzleNumber = await prisma.dailyPuzzle.count({
where: {
genreId: null,
specialId: null,
date: {
lte: puzzle.date
}
}
});
}
return {
id: recipient.comment.id,
message: recipient.comment.message,
createdAt: recipient.comment.createdAt,
readAt: recipient.readAt,
puzzle: {
id: puzzle.id,
date: puzzle.date,
puzzleNumber: puzzleNumber,
song: {
title: puzzle.song.title,
artist: puzzle.song.artist
},
genre: puzzle.genre ? {
id: puzzle.genre.id,
name: puzzle.genre.name
} : null,
special: puzzle.special ? {
id: puzzle.special.id,
name: puzzle.special.name
} : null
}
};
}));
return NextResponse.json(formattedComments);
} catch (error) {
console.error('Error fetching curator comments:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useTranslations, useLocale } from 'next-intl';
interface Genre {
id: number;
@@ -37,6 +37,30 @@ interface CuratorInfo {
specialIds: number[];
}
interface CuratorComment {
id: number;
message: string;
createdAt: string;
readAt: string | null;
puzzle: {
id: number;
date: string;
puzzleNumber: number;
song: {
title: string;
artist: string;
};
genre: {
id: number;
name: any;
} | null;
special: {
id: number;
name: any;
} | null;
};
}
function getCuratorAuthHeaders() {
const authToken = localStorage.getItem('hoerdle_curator_auth');
const username = localStorage.getItem('hoerdle_curator_username') || '';
@@ -58,6 +82,7 @@ function getCuratorUploadHeaders() {
export default function CuratorPageClient() {
const t = useTranslations('Curator');
const tNav = useTranslations('Navigation');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -99,6 +124,11 @@ export default function CuratorPageClient() {
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');
const storedUsername = localStorage.getItem('hoerdle_curator_username');
@@ -109,15 +139,70 @@ export default function CuratorPageClient() {
}
}, []);
const bootstrapCuratorData = async () => {
try {
setLoading(true);
await Promise.all([fetchCuratorInfo(), fetchSongs(), fetchGenres(), fetchSpecials()]);
await Promise.all([fetchCuratorInfo(), fetchSongs(), fetchGenres(), fetchSpecials(), fetchComments()]);
} finally {
setLoading(false);
}
};
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 archiveComment = async (commentId: number) => {
try {
const res = await fetch(`/api/curator-comments/${commentId}/archive`, {
method: 'POST',
headers: getCuratorAuthHeaders(),
});
if (res.ok) {
// Remove comment from local state (archived comments are not shown)
setComments(comments.filter(c => c.id !== commentId));
} else {
setMessage(t('archiveCommentError'));
}
} catch (error) {
console.error('Error archiving comment:', error);
setMessage(t('archiveCommentError'));
}
};
const fetchCuratorInfo = async () => {
const res = await fetch('/api/curator/me', {
headers: getCuratorAuthHeaders(),
@@ -633,6 +718,167 @@ export default function CuratorPageClient() {
<p style={{ marginBottom: '1rem', color: '#b91c1c', whiteSpace: 'pre-line' }}>{message}</p>
)}
{/* Comments Section */}
{(() => {
const unreadCount = comments.filter(c => !c.readAt).length;
const hasUnread = unreadCount > 0;
return (
<section style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.75rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: 0 }}>
{t('commentsTitle')} ({comments.length})
</h2>
{hasUnread && (
<span style={{
padding: '0.25rem 0.75rem',
borderRadius: '1rem',
background: '#ef4444',
color: 'white',
fontSize: '0.875rem',
fontWeight: 'bold',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
verticalAlign: 'baseline',
lineHeight: '1'
}}>
{unreadCount} {t('newComments')}
</span>
)}
</div>
<button
onClick={() => {
setShowComments(!showComments);
}}
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 specialName = comment.puzzle.special
? typeof comment.puzzle.special.name === 'string'
? comment.puzzle.special.name
: comment.puzzle.special.name?.de ?? comment.puzzle.special.name?.en
: null;
const isRead = comment.readAt !== null;
// Determine category label
let categoryLabel = '';
if (specialName) {
categoryLabel = `${specialName}`;
} else if (genreName) {
categoryLabel = genreName;
} else {
categoryLabel = tNav('global');
}
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.95rem' }}>
Hördle #{comment.puzzle.puzzleNumber}
</strong>
<span style={{ marginLeft: '0.5rem', fontSize: '0.85rem', color: '#6b7280' }}>
({categoryLabel})
</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.75rem', fontSize: '0.9rem', fontWeight: '500' }}>
{comment.puzzle.song.title} - {comment.puzzle.song.artist}
</div>
<div style={{ fontSize: '0.9rem', whiteSpace: 'pre-wrap', marginBottom: '0.5rem' }}>
{comment.message}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.5rem' }}>
<button
onClick={(e) => {
e.stopPropagation();
if (confirm(t('archiveCommentConfirm'))) {
archiveComment(comment.id);
}
}}
style={{
padding: '0.4rem 0.8rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: '#fff',
color: '#6b7280',
cursor: 'pointer',
fontSize: '0.85rem',
}}
title={t('archiveComment')}
>
{t('archiveComment')}
</button>
</div>
</div>
);
})}
</div>
)}
</>
)}
</section>
);
})()}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>{t('uploadSectionTitle')}</h2>
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>

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>
)}

View File

@@ -57,6 +57,13 @@
"points": "Punkte",
"skipBonus": "Bonus überspringen",
"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",
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
"skipped": "Übersprungen",
@@ -232,7 +239,20 @@
"loadingData": "Lade Daten...",
"loggedInAs": "Eingeloggt als {username}",
"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",
"archiveComment": "Archivieren",
"archiveCommentConfirm": "Möchtest du diesen Kommentar wirklich archivieren?",
"archiveCommentError": "Fehler beim Archivieren des Kommentars.",
"newComments": "neu"
},
"About": {
"title": "Über Hördle & Impressum",

View File

@@ -57,6 +57,13 @@
"points": "points",
"skipBonus": "Skip Bonus",
"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",
"actuallyReleasedIn": "Actually released in",
"skipped": "Skipped",
@@ -232,7 +239,20 @@
"loadingData": "Loading data...",
"loggedInAs": "Logged in as {username}",
"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",
"archiveComment": "Archive",
"archiveCommentConfirm": "Do you really want to archive this comment?",
"archiveCommentError": "Error archiving comment.",
"newComments": "new"
},
"About": {
"title": "About Hördle & Imprint",

View File

@@ -1,6 +1,6 @@
{
"name": "hoerdle",
"version": "0.1.5.2",
"version": "0.1.6.0",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -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");

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CuratorCommentRecipient" ADD COLUMN "archived" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -34,6 +34,7 @@ model Genre {
songs Song[]
dailyPuzzles DailyPuzzle[]
curatorGenres CuratorGenre[]
comments CuratorComment[]
}
model Special {
@@ -73,6 +74,7 @@ model DailyPuzzle {
genre Genre? @relation(fields: [genreId], references: [id])
specialId Int?
special Special? @relation(fields: [specialId], references: [id])
comments CuratorComment[]
@@unique([date, genreId, specialId])
}
@@ -114,6 +116,7 @@ model Curator {
genres CuratorGenre[]
specials CuratorSpecial[]
commentRecipients CuratorCommentRecipient[]
}
model CuratorGenre {
@@ -149,3 +152,32 @@ model PoliticalStatement {
@@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], onDelete: SetNull)
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?
archived Boolean @default(false)
@@unique([commentId, curatorId])
@@index([curatorId])
}

77
scripts/backup-restic.sh Executable file
View File

@@ -0,0 +1,77 @@
#!/bin/bash
# Restic backup script for Hördle deployment
# Creates a backup snapshot with tags and handles errors gracefully
set -e
echo "💾 Creating Restic backup..."
if ! command -v restic >/dev/null 2>&1; then
echo "⚠️ restic not found in PATH, skipping Restic backup"
exit 0
fi
# Check required environment variables
if [ -z "$RESTIC_PASSWORD" ]; then
echo "⚠️ RESTIC_PASSWORD not set, skipping Restic backup"
exit 0
fi
if [ -z "$RESTIC_AUTH_USER" ] || [ -z "$RESTIC_AUTH_PASSWORD" ]; then
echo "⚠️ RESTIC_AUTH_USER or RESTIC_AUTH_PASSWORD not set, skipping Restic backup"
exit 0
fi
# Build repository URL
RESTIC_REPO="rest:https://${RESTIC_AUTH_USER}:${RESTIC_AUTH_PASSWORD}@restic.elpatron.me/"
# Get current commit hash for tagging
CURRENT_COMMIT_SHORT="$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')"
CURRENT_DATE="$(date +%Y-%m-%d)"
# Export password for restic
export RESTIC_PASSWORD
# Check if repository exists, initialize if not
if ! restic -r "$RESTIC_REPO" snapshots >/dev/null 2>&1; then
echo " Initializing Restic repository..."
if ! restic -r "$RESTIC_REPO" init >/dev/null 2>&1; then
echo "⚠️ Failed to initialize Restic repository, skipping backup"
exit 0
fi
fi
# Create backup with tags
# Backup important directories: backups, config files, but exclude node_modules, .git, etc.
echo " Creating Restic snapshot..."
RESTIC_EXIT_CODE=0
restic -r "$RESTIC_REPO" backup \
--tag deployment \
--tag hoerdle \
--tag "date:${CURRENT_DATE}" \
--tag "commit:${CURRENT_COMMIT_SHORT}" \
--exclude='.git' \
--exclude='node_modules' \
--exclude='.next' \
--exclude='*.log' \
./backups \
./data \
./public/uploads \
docker-compose.yml \
.env \
package.json \
prisma/schema.prisma \
prisma/migrations \
scripts/ || RESTIC_EXIT_CODE=$?
if [ $RESTIC_EXIT_CODE -eq 0 ]; then
echo "✅ Restic backup completed successfully"
exit 0
elif [ $RESTIC_EXIT_CODE -eq 3 ]; then
echo "⚠️ Restic backup completed with warnings (some files could not be read), continuing..."
exit 0
else
echo "⚠️ Restic backup failed (exit code: $RESTIC_EXIT_CODE), continuing deployment..."
exit 0
fi

View File

@@ -59,6 +59,9 @@ else
echo "⚠️ Could not determine database path from config files"
fi
# Restic backup to remote repository
./scripts/backup-restic.sh
# Nur neueste Version holen (shallow fetch), vollständiges Repo ist im Deployment nicht nötig
echo "📥 Fetching latest commit (shallow clone) from git..."
git fetch --prune --tags --depth=1 origin master