Compare commits

..

4 Commits

18 changed files with 990 additions and 223 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.
- **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).

View File

@@ -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={{
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>
<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>
)}
<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>

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

@@ -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={{
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>
<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>
)}
<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>

View File

@@ -1,102 +1,51 @@
'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(() => {
const fetchSpecial = async () => {
try {
const res = await fetch(`/api/specials/${specialId}`);
if (res.ok) {
const data = await res.json();
setSpecial(data);
}
} catch (error) {
console.error('Error fetching special:', error);
} finally {
setLoading(false);
}
};
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);
} finally {
setLoading(false);
}
};
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, startTime }),
});
const handleStartTimeChange = (newStartTime: number) => {
setPendingStartTime(newStartTime);
setHasUnsavedChanges(true);
};
const handleSave = async () => {
if (!special || !selectedSongId || pendingStartTime === null) return;
setSaving(true);
try {
const res = await fetch(`/api/specials/${specialId}/songs`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songId: selectedSongId, startTime: pendingStartTime })
});
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}
/>
</div>
</div>
)}
</div>
)}
</div>
<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."
/>
);
}

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

@@ -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={{

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

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

@@ -274,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",

View File

@@ -274,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",

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