Compare commits

...

7 Commits

23 changed files with 1923 additions and 991 deletions

View File

@@ -61,6 +61,8 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren. - **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren.
- **Spieler-Kommentare:** - **Spieler-Kommentare:**
- **Feedback an Kuratoren:** Spieler können nach Abschluss eines Rätsels optional eine Nachricht an die Kuratoren senden. - **Feedback an Kuratoren:** Spieler können nach Abschluss eines Rätsels optional eine Nachricht an die Kuratoren senden.
- **KI-gestützte Formulierungshilfe:** Nachrichten können vor dem Absenden auf Wunsch automatisch von einer KI umformuliert/verbessert werden.
- **Einklappbares Kommentar-Formular:** Das Nachrichtenformular ist dezent als einklappbarer Bereich eingebunden und stört den Spielfluss nicht.
- **Automatische Zuordnung:** Kommentare werden automatisch an relevante Kuratoren verteilt (Genre-Kuratoren, Special-Kuratoren, Global-Kuratoren). - **Automatische Zuordnung:** Kommentare werden automatisch an relevante Kuratoren verteilt (Genre-Kuratoren, Special-Kuratoren, Global-Kuratoren).
- **Rate-Limiting:** Pro Spieler nur ein Kommentar pro Puzzle möglich. - **Rate-Limiting:** Pro Spieler nur ein Kommentar pro Puzzle möglich.
- **Kontext-Informationen:** Kommentare enthalten vollständigen Rätsel-Kontext (Hördle #, Genre/Special, Titel/Artist). - **Kontext-Informationen:** Kommentare enthalten vollständigen Rätsel-Kontext (Hördle #, Genre/Special, Titel/Artist).

View File

@@ -1320,20 +1320,45 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
</form> </form>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{specials.map(special => ( {specials.map(special => (
<div key={special.id} style={{ <div
key={special.id}
style={{
background: '#f3f4f6', background: '#f3f4f6',
padding: '0.25rem 0.75rem', padding: '0.25rem 0.75rem',
borderRadius: '999px', borderRadius: '999px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '0.5rem', gap: '0.5rem',
fontSize: '0.875rem' fontSize: '0.875rem',
}}> }}
<span>{getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})</span> >
{special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {getLocalizedValue(special.subtitle, activeTab)}</span>} <span>
<Link href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>{t('curate')}</Link> {getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})
<button onClick={() => startEditSpecial(special)} className="btn-secondary" style={{ marginRight: '0.5rem' }}>{t('edit')}</button> </span>
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">{t('delete')}</button> {special.subtitle && (
<span
style={{
fontSize: '0.75rem',
color: '#666',
marginLeft: '0.25rem',
}}
>
- {getLocalizedValue(special.subtitle, activeTab)}
</span>
)}
<button
onClick={() => startEditSpecial(special)}
className="btn-secondary"
style={{ marginRight: '0.5rem' }}
>
{t('edit')}
</button>
<button
onClick={() => handleDeleteSpecial(special.id)}
className="btn-danger"
>
{t('delete')}
</button>
</div> </div>
))} ))}
</div> </div>

View File

@@ -0,0 +1,7 @@
'use client';
import SpecialEditorPage from '@/app/admin/specials/[id]/page';
export default SpecialEditorPage;

View File

@@ -0,0 +1,7 @@
'use client';
import CuratorSpecialEditorPage from '@/app/curator/specials/[id]/page';
export default CuratorSpecialEditorPage;

View File

@@ -0,0 +1,7 @@
'use client';
import CuratorSpecialsPage from '@/app/curator/specials/page';
export default CuratorSpecialsPage;

View File

@@ -80,3 +80,30 @@ export async function submitRating(songId: number, rating: number, genre?: strin
return { success: false, error: 'Failed to submit rating' }; return { success: false, error: 'Failed to submit rating' };
} }
} }
export async function sendCommentNotification(puzzleId: number, message: string, originalMessage?: string, genre?: string | null) {
try {
const title = `New Curator Comment (Puzzle #${puzzleId})`;
let body = message;
if (originalMessage && originalMessage !== message) {
body = `Original: ${originalMessage}\n\nRewritten: ${message}`;
}
if (genre) {
body = `[${genre}] ${body}`;
}
await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
message: body,
priority: 5,
}),
});
} catch (error) {
console.error('Error sending comment notification:', error);
}
}

View File

@@ -638,20 +638,45 @@ export default function AdminPage() {
</form> </form>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{specials.map(special => ( {specials.map(special => (
<div key={special.id} style={{ <div
key={special.id}
style={{
background: '#f3f4f6', background: '#f3f4f6',
padding: '0.25rem 0.75rem', padding: '0.25rem 0.75rem',
borderRadius: '999px', borderRadius: '999px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '0.5rem', gap: '0.5rem',
fontSize: '0.875rem' fontSize: '0.875rem',
}}> }}
<span>{special.name} ({special._count?.songs || 0})</span> >
{special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {special.subtitle}</span>} <span>
<a href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>Curate</a> {special.name} ({special._count?.songs || 0})
<button onClick={() => startEditSpecial(special)} className="btn-secondary" style={{ marginRight: '0.5rem' }}>Edit</button> </span>
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button> {special.subtitle && (
<span
style={{
fontSize: '0.75rem',
color: '#666',
marginLeft: '0.25rem',
}}
>
- {special.subtitle}
</span>
)}
<button
onClick={() => startEditSpecial(special)}
className="btn-secondary"
style={{ marginRight: '0.5rem' }}
>
Edit
</button>
<button
onClick={() => handleDeleteSpecial(special.id)}
className="btn-danger"
>
Delete
</button>
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,60 +1,29 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter, usePathname } from 'next/navigation';
import WaveformEditor from '@/components/WaveformEditor'; import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
interface Song {
id: number;
title: string;
artist: string;
filename: string;
}
interface SpecialSong {
id: number;
songId: number;
startTime: number;
order: number | null;
song: Song;
}
interface Special {
id: number;
name: string;
subtitle?: string;
maxAttempts: number;
unlockSteps: string;
songs: SpecialSong[];
}
export default function SpecialEditorPage() { export default function SpecialEditorPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const specialId = params.id as string; const specialId = params.id as string;
const [special, setSpecial] = useState<Special | null>(null); // Locale aus dem Pfad ableiten (/en/..., /de/...)
const [selectedSongId, setSelectedSongId] = useState<number | null>(null); const localeFromPath = pathname?.split('/')[1] as 'de' | 'en' | undefined;
const locale: 'de' | 'en' = localeFromPath === 'de' || localeFromPath === 'en' ? localeFromPath : 'en';
const [special, setSpecial] = useState<CurateSpecial | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pendingStartTime, setPendingStartTime] = useState<number | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
useEffect(() => { useEffect(() => {
fetchSpecial();
}, [specialId]);
const fetchSpecial = async () => { const fetchSpecial = async () => {
try { try {
const res = await fetch(`/api/specials/${specialId}`); const res = await fetch(`/api/specials/${specialId}`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setSpecial(data); setSpecial(data);
if (data.songs.length > 0) {
setSelectedSongId(data.songs[0].songId);
// Initialize pendingStartTime with the current startTime of the first song
setPendingStartTime(data.songs[0].startTime);
}
} }
} catch (error) { } catch (error) {
console.error('Error fetching special:', error); console.error('Error fetching special:', error);
@@ -63,40 +32,20 @@ export default function SpecialEditorPage() {
} }
}; };
const handleStartTimeChange = (newStartTime: number) => { fetchSpecial();
setPendingStartTime(newStartTime); }, [specialId]);
setHasUnsavedChanges(true);
};
const handleSave = async () => { const handleSaveStartTime = async (songId: number, startTime: number) => {
if (!special || !selectedSongId || pendingStartTime === null) return;
setSaving(true);
try {
const res = await fetch(`/api/specials/${specialId}/songs`, { const res = await fetch(`/api/specials/${specialId}/songs`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songId: selectedSongId, startTime: pendingStartTime }) body: JSON.stringify({ songId, startTime }),
}); });
if (res.ok) { if (!res.ok) {
// Update local state const errorText = await res.text().catch(() => res.statusText || 'Unknown error');
setSpecial(prev => { console.error('Error updating special song (admin):', res.status, errorText);
if (!prev) return prev; throw new Error(`Failed to save start time: ${errorText}`);
return {
...prev,
songs: prev.songs.map(ss =>
ss.songId === selectedSongId ? { ...ss, startTime: pendingStartTime } : ss
)
};
});
setHasUnsavedChanges(false);
setPendingStartTime(null); // Reset pending state after saving
}
} catch (error) {
console.error('Error updating start time:', error);
} finally {
setSaving(false);
} }
}; };
@@ -117,116 +66,16 @@ export default function SpecialEditorPage() {
); );
} }
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId);
const unlockSteps = JSON.parse(special.unlockSteps);
const totalDuration = unlockSteps[unlockSteps.length - 1];
return ( return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}> <CurateSpecialEditor
<div style={{ marginBottom: '2rem' }}> special={special}
<button locale={locale}
onClick={() => router.push('/admin')} onBack={() => router.push('/admin')}
style={{ onSaveStartTime={handleSaveStartTime}
padding: '0.5rem 1rem', backLabel="← Back to Admin"
background: '#e5e7eb', headerPrefix="Edit Special:"
border: 'none', noSongsSubHint="Go back to the admin dashboard to add songs to this special."
borderRadius: '0.5rem',
cursor: 'pointer',
marginBottom: '1rem'
}}
>
Back to Admin
</button>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
Edit Special: {special.name}
</h1>
{special.subtitle && (
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
{special.subtitle}
</p>
)}
<p style={{ color: '#666', marginTop: '0.5rem' }}>
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
</p>
</div>
{special.songs.length === 0 ? (
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
<p>No songs assigned to this special yet.</p>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
Go back to the admin dashboard to add songs to this special.
</p>
</div>
) : (
<div>
<div style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Select Song to Curate
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
{special.songs.map(ss => (
<div
key={ss.songId}
onClick={() => setSelectedSongId(ss.songId)}
style={{
padding: '1rem',
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
color: selectedSongId === ss.songId ? 'white' : 'black',
borderRadius: '0.5rem',
cursor: 'pointer',
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
}}
>
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
Start: {ss.startTime}s
</div>
</div>
))}
</div>
</div>
{selectedSpecialSong && (
<div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Curate: {selectedSpecialSong.song.title}
</h2>
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.
</p>
<button
onClick={handleSave}
disabled={!hasUnsavedChanges || saving}
style={{
padding: '0.5rem 1.5rem',
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
color: hasUnsavedChanges ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '0.5rem',
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
fontWeight: 'bold',
fontSize: '0.875rem',
whiteSpace: 'nowrap'
}}
>
{saving ? '💾 Saving...' : hasUnsavedChanges ? '💾 Save Changes' : '✓ Saved'}
</button>
</div>
<WaveformEditor
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
duration={totalDuration}
unlockSteps={unlockSteps}
onStartTimeChange={handleStartTimeChange}
/> />
</div>
</div>
)}
</div>
)}
</div>
); );
} }

View File

@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
if (rateLimitError) return rateLimitError; if (rateLimitError) return rateLimitError;
try { try {
const { puzzleId, genreId, message, playerIdentifier } = await request.json(); const { puzzleId, genreId, message, playerIdentifier, originalMessage } = await request.json();
// Validate required fields // Validate required fields
if (!puzzleId || !message || !playerIdentifier) { if (!puzzleId || !message || !playerIdentifier) {
@@ -28,9 +28,9 @@ export async function POST(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
if (trimmedMessage.length > 2000) { if (trimmedMessage.length > 300) {
return NextResponse.json( return NextResponse.json(
{ error: 'Message too long. Maximum 2000 characters allowed.' }, { error: 'Message too long. Maximum 300 characters allowed.' },
{ status: 400 } { status: 400 }
); );
} }
@@ -170,6 +170,19 @@ export async function POST(request: NextRequest) {
return comment; return comment;
}); });
// Send Gotify notification (fire and forget)
const { sendCommentNotification } = await import('@/app/actions');
// originalMessage is already available from the initial request.json() call
// Determine genre name for notification
let genreName: string | null = null;
if (finalGenreId) {
const genreObj = await prisma.genre.findUnique({ where: { id: finalGenreId } });
if (genreObj) genreName = genreObj.name as string;
}
sendCommentNotification(Number(puzzleId), trimmedMessage, originalMessage, genreName || null);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
commentId: result.id commentId: result.id

View File

@@ -0,0 +1,58 @@
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,
{ params }: { params: Promise<{ id: string }> }
) {
const { error, context } = await requireStaffAuth(request);
if (error || !context) return error!;
if (context.role !== 'curator') {
return NextResponse.json(
{ error: 'Only curators can access this endpoint' },
{ status: 403 }
);
}
const { id } = await params;
const specialId = Number(id);
if (!specialId || Number.isNaN(specialId)) {
return NextResponse.json({ error: 'Invalid special id' }, { status: 400 });
}
// Prüfen, ob dieses Special dem Kurator zugeordnet ist
const assignment = await prisma.curatorSpecial.findFirst({
where: { curatorId: context.curator.id, specialId },
});
if (!assignment) {
return NextResponse.json(
{ error: 'Forbidden: You are not allowed to access this special' },
{ status: 403 }
);
}
const special = await prisma.special.findUnique({
where: { id: specialId },
include: {
songs: {
include: {
song: true,
},
orderBy: { order: 'asc' },
},
},
});
if (!special) {
return NextResponse.json({ error: 'Special not found' }, { status: 404 });
}
return NextResponse.json(special);
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireStaffAuth } from '@/lib/auth';
const prisma = new PrismaClient();
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { error, context } = await requireStaffAuth(request);
if (error || !context) return error!;
if (context.role !== 'curator') {
return NextResponse.json(
{ error: 'Only curators can access this endpoint' },
{ status: 403 }
);
}
try {
const { id } = await params;
const specialId = Number(id);
const { songId, startTime, order } = await request.json();
if (!specialId || Number.isNaN(specialId)) {
return NextResponse.json({ error: 'Invalid special id' }, { status: 400 });
}
if (!songId || typeof startTime !== 'number') {
return NextResponse.json({ error: 'Missing songId or startTime' }, { status: 400 });
}
// Prüfen, ob dieses Special dem Kurator zugeordnet ist
const assignment = await prisma.curatorSpecial.findFirst({
where: { curatorId: context.curator.id, specialId },
});
if (!assignment) {
return NextResponse.json(
{ error: 'Forbidden: You are not allowed to edit this special' },
{ status: 403 }
);
}
const specialSong = await prisma.specialSong.update({
where: {
specialId_songId: {
specialId,
songId,
},
},
data: {
startTime,
order,
},
include: {
song: true,
},
});
return NextResponse.json(specialSong);
} catch (e) {
console.error('Error updating curator special song:', e);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,47 @@
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) {
const { error, context } = await requireStaffAuth(request);
if (error || !context) return error!;
if (context.role !== 'curator') {
return NextResponse.json(
{ error: 'Only curators can access this endpoint' },
{ status: 403 }
);
}
// Specials, die diesem Kurator zugewiesen sind
const assignments = await prisma.curatorSpecial.findMany({
where: { curatorId: context.curator.id },
select: { specialId: true },
});
if (assignments.length === 0) {
return NextResponse.json([]);
}
const specialIds = assignments.map(a => a.specialId);
const specials = await prisma.special.findMany({
where: { id: { in: specialIds } },
include: {
songs: true,
},
orderBy: { id: 'asc' },
});
const result = specials.map(special => ({
id: special.id,
name: special.name,
songCount: special.songs.length,
}));
return NextResponse.json(result);
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
const OPENROUTER_MODEL = 'anthropic/claude-3.5-haiku';
export async function POST(request: NextRequest) {
try {
const { message } = await request.json();
if (!message || typeof message !== 'string') {
return NextResponse.json(
{ error: 'Message is required and must be a string' },
{ status: 400 }
);
}
if (!OPENROUTER_API_KEY) {
console.error('OPENROUTER_API_KEY is not configured');
// Fallback: return original message if API key is missing
return NextResponse.json({ rewrittenMessage: message });
}
const prompt = `Rewrite the following message to express the COMPLETE OPPOSITE meaning and opinion.
If the message is negative or critical, rewrite it to be overwhelmingly positive, praising, and appreciative.
If the message is positive, rewrite it to be critical or negative.
Maintain the original language (German or English).
Return ONLY the rewritten message text, nothing else.
Message: "${message}"`;
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://hoerdle.elpatron.me',
'X-Title': 'Hördle Message Rewriter'
},
body: JSON.stringify({
model: OPENROUTER_MODEL,
messages: [
{
role: 'user',
content: prompt
}
],
temperature: 0.7,
max_tokens: 500
})
});
if (!response.ok) {
console.error('OpenRouter API error:', await response.text());
// Fallback: return original message
return NextResponse.json({ rewrittenMessage: message });
}
const data = await response.json();
let rewrittenMessage = data.choices?.[0]?.message?.content?.trim() || message;
// Add suffix
rewrittenMessage += " (autocorrected by Polite-Bot)";
return NextResponse.json({ rewrittenMessage });
} catch (error) {
console.error('Error rewriting message:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -807,6 +807,25 @@ export default function CuratorPageClient() {
)} )}
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Link
href="/curator/specials"
style={{
padding: '0.5rem 1rem',
background: '#10b981',
color: 'white',
textDecoration: 'none',
borderRadius: '0.375rem',
fontSize: '0.9rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
lineHeight: '1.5',
boxSizing: 'border-box',
fontFamily: 'inherit',
}}
>
{t('curateSpecialsButton')}
</Link>
<Link <Link
href="/curator/help" href="/curator/help"
style={{ style={{

View File

@@ -0,0 +1,137 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter, usePathname } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
export default function CuratorSpecialEditorPage() {
const params = useParams();
const router = useRouter();
const pathname = usePathname();
const urlLocale = pathname?.split('/')[1] as 'de' | 'en' | undefined;
const intlLocale = useLocale() as 'de' | 'en';
const locale: 'de' | 'en' = urlLocale === 'de' || urlLocale === 'en' ? urlLocale : intlLocale;
const t = useTranslations('Curator');
const specialId = params?.id as string;
const [special, setSpecial] = useState<CurateSpecial | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSpecial = async () => {
try {
setLoading(true);
const res = await fetch(`/api/curator/specials/${specialId}`, {
headers: getCuratorAuthHeaders(),
});
if (res.status === 403) {
setError(t('specialForbidden'));
return;
}
if (!res.ok) {
setError('Failed to load special');
return;
}
const data = await res.json();
setSpecial(data);
} catch (e) {
setError('Failed to load special');
} finally {
setLoading(false);
}
};
if (specialId) {
fetchSpecial();
}
}, [specialId, t]);
const handleSaveStartTime = async (songId: number, startTime: number) => {
const res = await fetch(`/api/curator/specials/${specialId}/songs`, {
method: 'PUT',
headers: {
...getCuratorAuthHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ songId, startTime }),
});
if (res.status === 403) {
setError(t('specialForbidden'));
} else if (!res.ok) {
setError('Failed to save changes');
}
};
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('loadingData')}</p>
</div>
);
}
if (error) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{error}</p>
<button
onClick={() => router.push(`/${locale}/curator`)}
style={{
marginTop: '1rem',
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
border: 'none',
background: '#e5e7eb',
cursor: 'pointer',
}}
>
{t('backToDashboard')}
</button>
</div>
);
}
if (!special) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('specialNotFound')}</p>
<button
onClick={() => router.push(`/${locale}/curator`)}
style={{
marginTop: '1rem',
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
border: 'none',
background: '#e5e7eb',
cursor: 'pointer',
}}
>
{t('backToDashboard')}
</button>
</div>
);
}
return (
<CurateSpecialEditor
special={special}
locale={locale}
onBack={() => router.push(`/${locale}/curator/specials`)}
onSaveStartTime={handleSaveStartTime}
backLabel={t('backToCuratorSpecials')}
headerPrefix={t('curateSpecialHeaderPrefix')}
noSongsHint={t('curateSpecialNoSongs')}
noSongsSubHint={t('curateSpecialNoSongsSub')}
instructionsText={t('curateSpecialInstructions')}
savingLabel={t('saving')}
saveChangesLabel={t('saveChanges')}
savedLabel={t('saved')}
/>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Link } from '@/lib/navigation';
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
type LocalizedString = string | { de: string; en: string };
interface CuratorSpecialSummary {
id: number;
name: LocalizedString;
songCount: number;
}
export default function CuratorSpecialsPage() {
const t = useTranslations('Curator');
const locale = useLocale();
const [specials, setSpecials] = useState<CuratorSpecialSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSpecials = async () => {
try {
setLoading(true);
const res = await fetch('/api/curator/specials', {
headers: getCuratorAuthHeaders(),
});
if (res.ok) {
const data = await res.json();
setSpecials(data);
} else if (res.status === 403) {
setError(t('noSpecialPermissions'));
} else {
setError('Failed to load specials');
}
} catch (e) {
setError('Failed to load specials');
} finally {
setLoading(false);
}
};
fetchSpecials();
}, [t]);
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('loadingData')}</p>
</div>
);
}
if (error) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{error}</p>
</div>
);
}
if (specials.length === 0) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('noSpecialsInScope')}</p>
<div style={{ marginTop: '1.5rem' }}>
<Link
href="/curator"
style={{
padding: '0.5rem 1rem',
background: '#e5e7eb',
borderRadius: '0.5rem',
textDecoration: 'none',
color: '#111827',
fontSize: '0.9rem',
}}
>
{t('backToDashboard')}
</Link>
</div>
</div>
);
}
const resolveLocalized = (value: LocalizedString, locale: string): string => {
if (!value) return '';
if (typeof value === 'string') return value;
const loc = locale === 'de' || locale === 'en' ? locale : 'en';
return value[loc] ?? value.en ?? value.de;
};
return (
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{t('curateSpecialsTitle')}
</h1>
<Link
href="/curator"
style={{
padding: '0.5rem 1rem',
background: '#e5e7eb',
borderRadius: '0.5rem',
textDecoration: 'none',
color: '#111827',
fontSize: '0.9rem',
}}
>
{t('backToDashboard')}
</Link>
</div>
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>
{t('curateSpecialsDescription')}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
{specials.map(special => (
<Link
key={special.id}
href={`/curator/specials/${special.id}`}
style={{
padding: '1rem',
borderRadius: '0.75rem',
border: '1px solid #e5e7eb',
background: 'white',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
textDecoration: 'none',
color: 'inherit',
}}
>
<div>
<h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: '0.25rem' }}>
{resolveLocalized(special.name, String(locale))}
</h2>
<p style={{ fontSize: '0.875rem', color: '#6b7280' }}>
{t('curateSpecialSongCount', { count: special.songCount })}
</p>
</div>
<div style={{ marginTop: '0.75rem', textAlign: 'right', fontSize: '0.875rem', color: '#4f46e5' }}>
{t('curateSpecialOpen')}
</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,212 @@
'use client';
import { useState } from 'react';
import WaveformEditor from '@/components/WaveformEditor';
export type LocalizedString = string | { de: string; en: string };
export interface CurateSpecialSong {
id: number;
songId: number;
startTime: number;
order: number | null;
song: {
id: number;
title: string;
artist: string;
filename: string;
};
}
export interface CurateSpecial {
id: number;
name: LocalizedString;
subtitle?: LocalizedString | null;
maxAttempts: number;
unlockSteps: string;
songs: CurateSpecialSong[];
}
export interface CurateSpecialEditorProps {
special: CurateSpecial;
locale: 'de' | 'en';
onBack: () => void;
onSaveStartTime: (songId: number, startTime: number) => Promise<void>;
backLabel?: string;
headerPrefix?: string;
noSongsHint?: string;
noSongsSubHint?: string;
instructionsText?: string;
savingLabel?: string;
saveChangesLabel?: string;
savedLabel?: string;
}
const resolveLocalized = (value: LocalizedString | null | undefined, locale: 'de' | 'en'): string | undefined => {
if (!value) return undefined;
if (typeof value === 'string') return value;
return value[locale] ?? value.en ?? value.de;
};
export default function CurateSpecialEditor({
special,
locale,
onBack,
onSaveStartTime,
backLabel = '← Back',
headerPrefix = 'Edit Special:',
noSongsHint = 'No songs assigned to this special yet.',
noSongsSubHint = 'Go back to the dashboard to add songs to this special.',
instructionsText = 'Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.',
savingLabel = '💾 Saving...',
saveChangesLabel = '💾 Save Changes',
savedLabel = '✓ Saved',
}: CurateSpecialEditorProps) {
const [selectedSongId, setSelectedSongId] = useState<number | null>(
special.songs.length > 0 ? special.songs[0].songId : null
);
const [pendingStartTime, setPendingStartTime] = useState<number | null>(
special.songs.length > 0 ? special.songs[0].startTime : null
);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [saving, setSaving] = useState(false);
const specialName = resolveLocalized(special.name, locale) ?? `Special #${special.id}`;
const specialSubtitle = resolveLocalized(special.subtitle ?? null, locale);
const unlockSteps = JSON.parse(special.unlockSteps);
const totalDuration = unlockSteps[unlockSteps.length - 1];
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId) ?? null;
const handleStartTimeChange = (newStartTime: number) => {
setPendingStartTime(newStartTime);
setHasUnsavedChanges(true);
};
const handleSave = async () => {
if (!selectedSongId || pendingStartTime === null) return;
setSaving(true);
try {
await onSaveStartTime(selectedSongId, pendingStartTime);
setHasUnsavedChanges(false);
} finally {
setSaving(false);
}
};
return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ marginBottom: '2rem' }}>
<button
onClick={onBack}
style={{
padding: '0.5rem 1rem',
background: '#e5e7eb',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
marginBottom: '1rem'
}}
>
{backLabel}
</button>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{headerPrefix} {specialName}
</h1>
{specialSubtitle && (
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
{specialSubtitle}
</p>
)}
<p style={{ color: '#666', marginTop: '0.5rem' }}>
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
</p>
</div>
{special.songs.length === 0 ? (
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
<p>{noSongsHint}</p>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
{noSongsSubHint}
</p>
</div>
) : (
<div>
<div style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Select Song to Curate
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
{special.songs.map(ss => (
<div
key={ss.songId}
onClick={() => {
setSelectedSongId(ss.songId);
setPendingStartTime(ss.startTime);
setHasUnsavedChanges(false);
}}
style={{
padding: '1rem',
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
color: selectedSongId === ss.songId ? 'white' : 'black',
borderRadius: '0.5rem',
cursor: 'pointer',
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
}}
>
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
Start: {ss.startTime}s
</div>
</div>
))}
</div>
</div>
{selectedSpecialSong && (
<div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Curate: {selectedSpecialSong.song.title}
</h2>
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
{instructionsText}
</p>
<button
onClick={handleSave}
disabled={!hasUnsavedChanges || saving}
style={{
padding: '0.5rem 1.5rem',
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
color: hasUnsavedChanges ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '0.5rem',
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
fontWeight: 'bold',
fontSize: '0.875rem',
whiteSpace: 'nowrap'
}}
>
{saving ? savingLabel : hasUnsavedChanges ? saveChangesLabel : savedLabel}
</button>
</div>
<WaveformEditor
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
duration={totalDuration}
unlockSteps={unlockSteps}
onStartTimeChange={handleStartTimeChange}
/>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -65,6 +65,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [commentSending, setCommentSending] = useState(false); const [commentSending, setCommentSending] = useState(false);
const [commentSent, setCommentSent] = useState(false); const [commentSent, setCommentSent] = useState(false);
const [commentError, setCommentError] = useState<string | null>(null); const [commentError, setCommentError] = useState<string | null>(null);
const [commentCollapsed, setCommentCollapsed] = useState(true);
const [rewrittenMessage, setRewrittenMessage] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const updateCountdown = () => { const updateCountdown = () => {
@@ -321,6 +323,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
setCommentSending(true); setCommentSending(true);
setCommentError(null); setCommentError(null);
setRewrittenMessage(null);
try { try {
const playerIdentifier = getOrCreatePlayerId(); const playerIdentifier = getOrCreatePlayerId();
@@ -328,6 +331,26 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
throw new Error('Could not get player identifier'); throw new Error('Could not get player identifier');
} }
// 1. Rewrite message using AI
const rewriteResponse = await fetch('/api/rewrite-message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: commentText.trim() })
});
let finalMessage = commentText.trim();
if (rewriteResponse.ok) {
const rewriteData = await rewriteResponse.json();
if (rewriteData.rewrittenMessage) {
finalMessage = rewriteData.rewrittenMessage;
// If message was changed significantly (simple check), show it
if (finalMessage !== commentText.trim()) {
setRewrittenMessage(finalMessage);
}
}
}
// 2. Send comment
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle // 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 genreId = isSpecial ? null : null; // API will determine from puzzle
@@ -339,7 +362,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
body: JSON.stringify({ body: JSON.stringify({
puzzleId: dailyPuzzle.id, puzzleId: dailyPuzzle.id,
genreId: genreId, genreId: genreId,
message: commentText.trim(), message: finalMessage,
originalMessage: commentText.trim() !== finalMessage ? commentText.trim() : undefined,
playerIdentifier: playerIdentifier playerIdentifier: playerIdentifier
}) })
}); });
@@ -602,9 +626,24 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{/* Comment Form */} {/* Comment Form */}
{!commentSent && ( {!commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}> <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' }}> <div
onClick={() => setCommentCollapsed(!commentCollapsed)}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
marginBottom: commentCollapsed ? 0 : '1rem'
}}
>
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', margin: 0 }}>
{t('sendComment')} {t('sendComment')}
</h3> </h3>
<span>{commentCollapsed ? '▼' : '▲'}</span>
</div>
{!commentCollapsed && (
<>
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}> <p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
{t('commentHelp')} {t('commentHelp')}
</p> </p>
@@ -612,7 +651,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
value={commentText} value={commentText}
onChange={(e) => setCommentText(e.target.value)} onChange={(e) => setCommentText(e.target.value)}
placeholder={t('commentPlaceholder')} placeholder={t('commentPlaceholder')}
maxLength={2000} maxLength={300}
style={{ style={{
width: '100%', width: '100%',
minHeight: '100px', minHeight: '100px',
@@ -622,13 +661,14 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
fontSize: '0.9rem', fontSize: '0.9rem',
fontFamily: 'inherit', fontFamily: 'inherit',
resize: 'vertical', resize: 'vertical',
marginBottom: '0.5rem' marginBottom: '0.5rem',
display: 'block' // Ensure block display for proper alignment
}} }}
disabled={commentSending} disabled={commentSending}
/> />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}> <span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
{commentText.length}/2000 {commentText.length}/300
</span> </span>
{commentError && ( {commentError && (
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}> <span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
@@ -648,14 +688,22 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
> >
{commentSending ? t('sending') : t('sendComment')} {commentSending ? t('sending') : t('sendComment')}
</button> </button>
</>
)}
</div> </div>
)} )}
{commentSent && ( {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)' }}> <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' }}> <p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: rewrittenMessage ? '0.5rem' : 0 }}>
{t('commentSent')} {t('commentSent')}
</p> </p>
{rewrittenMessage && (
<div style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', textAlign: 'center' }}>
<p style={{ marginBottom: '0.25rem' }}>{t('commentRewritten')}</p>
<p style={{ fontStyle: 'italic' }}>"{rewrittenMessage}"</p>
</div>
)}
</div> </div>
)} )}

17
lib/curatorAuth.ts Normal file
View File

@@ -0,0 +1,17 @@
export function getCuratorAuthHeaders() {
if (typeof window === 'undefined') {
return {
'x-curator-auth': '',
'x-curator-username': '',
};
}
const authToken = localStorage.getItem('hoerdle_curator_auth');
const username = localStorage.getItem('hoerdle_curator_username') || '';
return {
'x-curator-auth': authToken || '',
'x-curator-username': username,
};
}

View File

@@ -58,9 +58,11 @@
"skipBonus": "Bonus überspringen", "skipBonus": "Bonus überspringen",
"notQuite": "Nicht ganz!", "notQuite": "Nicht ganz!",
"sendComment": "Nachricht an Kurator senden", "sendComment": "Nachricht an Kurator senden",
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres...", "sendCommentCollapsed": "Nachricht an Kurator senden",
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres... Bitte bleibe freundlich und höflich.",
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.", "commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.", "commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
"commentRewritten": "Deine Nachricht wurde automatisch freundlicher formuliert:",
"commentError": "Fehler beim Senden der Nachricht", "commentError": "Fehler beim Senden der Nachricht",
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.", "commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
"sending": "Wird gesendet...", "sending": "Wird gesendet...",
@@ -272,7 +274,25 @@
"noBatchOperations": "Keine Batch-Operationen angegeben", "noBatchOperations": "Keine Batch-Operationen angegeben",
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert", "batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
"batchUpdateError": "Fehler: {error}", "batchUpdateError": "Fehler: {error}",
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung" "batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung",
"backToDashboard": "Zurück zum Dashboard",
"curateSpecialsButton": "Specials kuratieren",
"curateSpecialsTitle": "Deine Specials kuratieren",
"curateSpecialsDescription": "Hier kannst du die Startzeiten der Songs in deinen zugewiesenen Specials für das Rätsel feinjustieren.",
"noSpecialPermissions": "Dir sind keine Specials zugeordnet.",
"noSpecialsInScope": "Keine Specials zum Kuratieren vorhanden.",
"curateSpecialSongCount": "{count, plural, one {# Song} other {# Songs}} in diesem Special",
"curateSpecialOpen": "Öffnen",
"specialForbidden": "Du darfst dieses Special nicht bearbeiten.",
"specialNotFound": "Special nicht gefunden.",
"backToCuratorSpecials": "Zurück zur Special-Übersicht",
"curateSpecialHeaderPrefix": "Special kuratieren:",
"curateSpecialNoSongs": "Diesem Special sind noch keine Songs zugeordnet.",
"curateSpecialNoSongsSub": "Gehe zurück zu deinem Dashboard, um diesem Special Songs zuzuordnen.",
"curateSpecialInstructions": "Klicke auf die Waveform, um zu wählen, wo das Rätsel starten soll. Der hervorgehobene Bereich zeigt, was die Spieler hören.",
"saving": "💾 Speichere...",
"saveChanges": "💾 Änderungen speichern",
"saved": "✓ Gespeichert"
}, },
"CuratorHelp": { "CuratorHelp": {
"title": "Kurator-Hilfe & Handbuch", "title": "Kurator-Hilfe & Handbuch",

View File

@@ -58,9 +58,11 @@
"skipBonus": "Skip Bonus", "skipBonus": "Skip Bonus",
"notQuite": "Not quite!", "notQuite": "Not quite!",
"sendComment": "Send message to curator", "sendComment": "Send message to curator",
"commentPlaceholder": "Write a message to the curators of this genre...", "sendCommentCollapsed": "Send message to curator",
"commentHelp": "Share your thoughts about the puzzle with the curators. Your message will be displayed to them.", "commentPlaceholder": "Write a message to the curators of this genre... Please remain friendly and polite.",
"commentHelp": "Share your thoughts on the puzzle with the curators. Your message will be shown to them.",
"commentSent": "✓ Message sent! Thank you for your feedback.", "commentSent": "✓ Message sent! Thank you for your feedback.",
"commentRewritten": "Your message was automatically rephrased to be more friendly:",
"commentError": "Error sending message", "commentError": "Error sending message",
"commentRateLimited": "You have already sent a message for this puzzle.", "commentRateLimited": "You have already sent a message for this puzzle.",
"sending": "Sending...", "sending": "Sending...",
@@ -272,7 +274,25 @@
"noBatchOperations": "No batch operations specified", "noBatchOperations": "No batch operations specified",
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs", "batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
"batchUpdateError": "Error: {error}", "batchUpdateError": "Error: {error}",
"batchUpdateNetworkError": "Network error during batch update" "batchUpdateNetworkError": "Network error during batch update",
"backToDashboard": "Back to dashboard",
"curateSpecialsButton": "Curate Specials",
"curateSpecialsTitle": "Curate your Specials",
"curateSpecialsDescription": "Here you can fine-tune the start times of the songs in your assigned specials for the puzzle.",
"noSpecialPermissions": "You do not have any specials assigned to you.",
"noSpecialsInScope": "No specials available for you to curate.",
"curateSpecialSongCount": "{count, plural, one {# song} other {# songs}} in this special",
"curateSpecialOpen": "Open",
"specialForbidden": "You are not allowed to edit this special.",
"specialNotFound": "Special not found.",
"backToCuratorSpecials": "Back to specials overview",
"curateSpecialHeaderPrefix": "Curate Special:",
"curateSpecialNoSongs": "No songs assigned to this special yet.",
"curateSpecialNoSongsSub": "Go back to your dashboard to add songs to this special.",
"curateSpecialInstructions": "Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.",
"saving": "💾 Saving...",
"saveChanges": "💾 Save Changes",
"saved": "✓ Saved"
}, },
"CuratorHelp": { "CuratorHelp": {
"title": "Curator Help & Manual", "title": "Curator Help & Manual",

View File

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

98
scripts/restore-restic.sh Normal file
View File

@@ -0,0 +1,98 @@
#!/bin/bash
# Restic restore script for Hördle deployment
# Restores files from the Restic repository created by backup-restic.sh
#
# Usage:
# scripts/restore-restic.sh [SNAPSHOT] [TARGET_DIR]
#
# SNAPSHOT : Optional. Restic snapshot reference (ID, tag, or "latest").
# Defaults to "latest".
# TARGET_DIR : Optional. Directory to restore into.
# Defaults to "./restic-restore-<DATE>-<TIME>".
#
# Examples:
# scripts/restore-restic.sh
# → Restore latest snapshot into a new timestamped directory
#
# scripts/restore-restic.sh latest ./restore-latest
# → Restore latest snapshot into ./restore-latest
#
# scripts/restore-restic.sh d3adb33f ./restore-commit
# → Restore specific snapshot ID into ./restore-commit
set -e
echo "💾 Restoring from Restic backup..."
if ! command -v restic >/dev/null 2>&1; then
echo "❌ restic nicht im PATH gefunden. Bitte installiere restic oder füge es zum PATH hinzu."
exit 1
fi
# Erforderliche Umgebungsvariablen prüfen
if [ -z "$RESTIC_PASSWORD" ]; then
echo "❌ RESTIC_PASSWORD ist nicht gesetzt. Abbruch."
exit 1
fi
if [ -z "$RESTIC_AUTH_USER" ] || [ -z "$RESTIC_AUTH_PASSWORD" ]; then
echo "❌ RESTIC_AUTH_USER oder RESTIC_AUTH_PASSWORD ist nicht gesetzt. Abbruch."
exit 1
fi
# Repository-URL auf Basis des Backup-Skripts
RESTIC_REPO="rest:https://${RESTIC_AUTH_USER}:${RESTIC_AUTH_PASSWORD}@restic.elpatron.me/"
# Passwort für restic exportieren
export RESTIC_PASSWORD
# Snapshot-Referenz und Zielverzeichnis bestimmen
SNAPSHOT_REF="${1:-latest}"
TIMESTAMP="$(date +%Y-%m-%d_%H-%M-%S)"
DEFAULT_TARGET_DIR="./restic-restore-${TIMESTAMP}"
TARGET_DIR="${2:-$DEFAULT_TARGET_DIR}"
echo " Repository : $RESTIC_REPO"
echo " Snapshot : $SNAPSHOT_REF"
echo " Zielordner : $TARGET_DIR"
# Prüfen, ob Repository existiert
if ! restic -r "$RESTIC_REPO" snapshots >/dev/null 2>&1; then
echo "❌ Kein gültiges Restic-Repository gefunden (oder keine Snapshots vorhanden)."
exit 1
fi
# Zielverzeichnis vorbereiten
if [ -e "$TARGET_DIR" ] && [ ! -d "$TARGET_DIR" ]; then
echo "$TARGET_DIR existiert und ist kein Verzeichnis. Abbruch."
exit 1
fi
if [ ! -d "$TARGET_DIR" ]; then
echo " Erstelle Zielverzeichnis $TARGET_DIR ..."
mkdir -p "$TARGET_DIR"
fi
echo " Verfügbare Snapshots (gekürzt):"
restic -r "$RESTIC_REPO" snapshots --compact || true
echo
echo " Starte Restic-Restore..."
RESTIC_EXIT_CODE=0
# Standard-Restore: gesamtes Repo in Zielverzeichnis
# (Das spiegelt die beim Backup gesicherten Pfade unterhalb von TARGET_DIR.)
restic -r "$RESTIC_REPO" restore "$SNAPSHOT_REF" \
--target "$TARGET_DIR" || RESTIC_EXIT_CODE=$?
if [ $RESTIC_EXIT_CODE -eq 0 ]; then
echo "✅ Restic-Restore erfolgreich abgeschlossen."
echo " Wiederhergestellte Daten befinden sich in: $TARGET_DIR"
exit 0
else
echo "⚠️ Restic-Restore fehlgeschlagen (Exit-Code: $RESTIC_EXIT_CODE)."
exit $RESTIC_EXIT_CODE
fi