Compare commits
7 Commits
v0.1.6.2
...
curate-spe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7121271a | ||
|
|
12cc81905e | ||
|
|
b46e9e3882 | ||
|
|
332688d693 | ||
|
|
a725694519 | ||
|
|
cdb9803b40 | ||
|
|
7db4e26b2c |
@@ -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.
|
||||
- **Spieler-Kommentare:**
|
||||
- **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).
|
||||
- **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).
|
||||
|
||||
@@ -1320,20 +1320,45 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
||||
</form>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{specials.map(special => (
|
||||
<div key={special.id} style={{
|
||||
<div
|
||||
key={special.id}
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
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>}
|
||||
<Link href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>{t('curate')}</Link>
|
||||
<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>
|
||||
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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
7
app/[locale]/admin/specials/[id]/page.tsx
Normal file
7
app/[locale]/admin/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import SpecialEditorPage from '@/app/admin/specials/[id]/page';
|
||||
|
||||
export default SpecialEditorPage;
|
||||
|
||||
|
||||
7
app/[locale]/curator/specials/[id]/page.tsx
Normal file
7
app/[locale]/curator/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import CuratorSpecialEditorPage from '@/app/curator/specials/[id]/page';
|
||||
|
||||
export default CuratorSpecialEditorPage;
|
||||
|
||||
|
||||
7
app/[locale]/curator/specials/page.tsx
Normal file
7
app/[locale]/curator/specials/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import CuratorSpecialsPage from '@/app/curator/specials/page';
|
||||
|
||||
export default CuratorSpecialsPage;
|
||||
|
||||
|
||||
@@ -80,3 +80,30 @@ export async function submitRating(songId: number, rating: number, genre?: strin
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,20 +638,45 @@ export default function AdminPage() {
|
||||
</form>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{specials.map(special => (
|
||||
<div key={special.id} style={{
|
||||
<div
|
||||
key={special.id}
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
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>}
|
||||
<a href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>Curate</a>
|
||||
<button onClick={() => startEditSpecial(special)} className="btn-secondary" style={{ marginRight: '0.5rem' }}>Edit</button>
|
||||
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button>
|
||||
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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -1,60 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import WaveformEditor from '@/components/WaveformEditor';
|
||||
|
||||
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[];
|
||||
}
|
||||
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
|
||||
|
||||
export default function SpecialEditorPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const specialId = params.id as string;
|
||||
|
||||
const [special, setSpecial] = useState<Special | null>(null);
|
||||
const [selectedSongId, setSelectedSongId] = useState<number | null>(null);
|
||||
// Locale aus dem Pfad ableiten (/en/..., /de/...)
|
||||
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 [saving, setSaving] = useState(false);
|
||||
const [pendingStartTime, setPendingStartTime] = useState<number | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSpecial();
|
||||
}, [specialId]);
|
||||
|
||||
const fetchSpecial = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/specials/${specialId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
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) {
|
||||
console.error('Error fetching special:', error);
|
||||
@@ -63,40 +32,20 @@ export default function SpecialEditorPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTimeChange = (newStartTime: number) => {
|
||||
setPendingStartTime(newStartTime);
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
fetchSpecial();
|
||||
}, [specialId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!special || !selectedSongId || pendingStartTime === null) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const handleSaveStartTime = async (songId: number, startTime: number) => {
|
||||
const res = await fetch(`/api/specials/${specialId}/songs`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ songId: selectedSongId, startTime: pendingStartTime })
|
||||
body: JSON.stringify({ songId, startTime }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Update local state
|
||||
setSpecial(prev => {
|
||||
if (!prev) return prev;
|
||||
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);
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => res.statusText || 'Unknown error');
|
||||
console.error('Error updating special song (admin):', res.status, errorText);
|
||||
throw new Error(`Failed to save start time: ${errorText}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<button
|
||||
onClick={() => router.push('/admin')}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
border: 'none',
|
||||
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}
|
||||
<CurateSpecialEditor
|
||||
special={special}
|
||||
locale={locale}
|
||||
onBack={() => router.push('/admin')}
|
||||
onSaveStartTime={handleSaveStartTime}
|
||||
backLabel="← Back to Admin"
|
||||
headerPrefix="Edit Special:"
|
||||
noSongsSubHint="Go back to the admin dashboard to add songs to this special."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
try {
|
||||
const { puzzleId, genreId, message, playerIdentifier } = await request.json();
|
||||
const { puzzleId, genreId, message, playerIdentifier, originalMessage } = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!puzzleId || !message || !playerIdentifier) {
|
||||
@@ -28,9 +28,9 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (trimmedMessage.length > 2000) {
|
||||
if (trimmedMessage.length > 300) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message too long. Maximum 2000 characters allowed.' },
|
||||
{ error: 'Message too long. Maximum 300 characters allowed.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -170,6 +170,19 @@ export async function POST(request: NextRequest) {
|
||||
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({
|
||||
success: true,
|
||||
commentId: result.id
|
||||
|
||||
58
app/api/curator/specials/[id]/route.ts
Normal file
58
app/api/curator/specials/[id]/route.ts
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
69
app/api/curator/specials/[id]/songs/route.ts
Normal file
69
app/api/curator/specials/[id]/songs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
app/api/curator/specials/route.ts
Normal file
47
app/api/curator/specials/route.ts
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
73
app/api/rewrite-message/route.ts
Normal file
73
app/api/rewrite-message/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -807,6 +807,25 @@ export default function CuratorPageClient() {
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
href="/curator/help"
|
||||
style={{
|
||||
|
||||
137
app/curator/specials/[id]/page.tsx
Normal file
137
app/curator/specials/[id]/page.tsx
Normal 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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
152
app/curator/specials/page.tsx
Normal file
152
app/curator/specials/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
212
components/CurateSpecialEditor.tsx
Normal file
212
components/CurateSpecialEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const [commentSending, setCommentSending] = useState(false);
|
||||
const [commentSent, setCommentSent] = useState(false);
|
||||
const [commentError, setCommentError] = useState<string | null>(null);
|
||||
const [commentCollapsed, setCommentCollapsed] = useState(true);
|
||||
const [rewrittenMessage, setRewrittenMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCountdown = () => {
|
||||
@@ -321,6 +323,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
setCommentSending(true);
|
||||
setCommentError(null);
|
||||
setRewrittenMessage(null);
|
||||
|
||||
try {
|
||||
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');
|
||||
}
|
||||
|
||||
// 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
|
||||
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({
|
||||
puzzleId: dailyPuzzle.id,
|
||||
genreId: genreId,
|
||||
message: commentText.trim(),
|
||||
message: finalMessage,
|
||||
originalMessage: commentText.trim() !== finalMessage ? commentText.trim() : undefined,
|
||||
playerIdentifier: playerIdentifier
|
||||
})
|
||||
});
|
||||
@@ -602,9 +626,24 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
{/* 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' }}>
|
||||
<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')}
|
||||
</h3>
|
||||
<span>{commentCollapsed ? '▼' : '▲'}</span>
|
||||
</div>
|
||||
|
||||
{!commentCollapsed && (
|
||||
<>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
|
||||
{t('commentHelp')}
|
||||
</p>
|
||||
@@ -612,7 +651,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder={t('commentPlaceholder')}
|
||||
maxLength={2000}
|
||||
maxLength={300}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '100px',
|
||||
@@ -622,13 +661,14 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical',
|
||||
marginBottom: '0.5rem'
|
||||
marginBottom: '0.5rem',
|
||||
display: 'block' // Ensure block display for proper alignment
|
||||
}}
|
||||
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
|
||||
{commentText.length}/300
|
||||
</span>
|
||||
{commentError && (
|
||||
<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')}
|
||||
</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' }}>
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: rewrittenMessage ? '0.5rem' : 0 }}>
|
||||
{t('commentSent')}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
17
lib/curatorAuth.ts
Normal file
17
lib/curatorAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -58,9 +58,11 @@
|
||||
"skipBonus": "Bonus überspringen",
|
||||
"notQuite": "Nicht ganz!",
|
||||
"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.",
|
||||
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
|
||||
"commentRewritten": "Deine Nachricht wurde automatisch freundlicher formuliert:",
|
||||
"commentError": "Fehler beim Senden der Nachricht",
|
||||
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
|
||||
"sending": "Wird gesendet...",
|
||||
@@ -272,7 +274,25 @@
|
||||
"noBatchOperations": "Keine Batch-Operationen angegeben",
|
||||
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
|
||||
"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": {
|
||||
"title": "Kurator-Hilfe & Handbuch",
|
||||
|
||||
@@ -58,9 +58,11 @@
|
||||
"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.",
|
||||
"sendCommentCollapsed": "Send message to curator",
|
||||
"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.",
|
||||
"commentRewritten": "Your message was automatically rephrased to be more friendly:",
|
||||
"commentError": "Error sending message",
|
||||
"commentRateLimited": "You have already sent a message for this puzzle.",
|
||||
"sending": "Sending...",
|
||||
@@ -272,7 +274,25 @@
|
||||
"noBatchOperations": "No batch operations specified",
|
||||
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
|
||||
"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": {
|
||||
"title": "Curator Help & Manual",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.6.2",
|
||||
"version": "0.1.6.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
98
scripts/restore-restic.sh
Normal file
98
scripts/restore-restic.sh
Normal 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user