Add curator special curation flow and shared editor
This commit is contained in:
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;
|
||||||
|
|
||||||
|
|
||||||
@@ -1,103 +1,46 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||||
import WaveformEditor from '@/components/WaveformEditor';
|
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
|
||||||
|
|
||||||
interface Song {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
artist: string;
|
|
||||||
filename: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpecialSong {
|
|
||||||
id: number;
|
|
||||||
songId: number;
|
|
||||||
startTime: number;
|
|
||||||
order: number | null;
|
|
||||||
song: Song;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Special {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
subtitle?: string;
|
|
||||||
maxAttempts: number;
|
|
||||||
unlockSteps: string;
|
|
||||||
songs: SpecialSong[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SpecialEditorPage() {
|
export default function SpecialEditorPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const specialId = params.id as string;
|
const specialId = params.id as string;
|
||||||
|
|
||||||
const [special, setSpecial] = useState<Special | null>(null);
|
// Locale aus dem Pfad ableiten (/en/..., /de/...)
|
||||||
const [selectedSongId, setSelectedSongId] = useState<number | null>(null);
|
const localeFromPath = pathname?.split('/')[1] as 'de' | 'en' | undefined;
|
||||||
|
const locale: 'de' | 'en' = localeFromPath === 'de' || localeFromPath === 'en' ? localeFromPath : 'en';
|
||||||
|
|
||||||
|
const [special, setSpecial] = useState<CurateSpecial | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [pendingStartTime, setPendingStartTime] = useState<number | null>(null);
|
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
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();
|
fetchSpecial();
|
||||||
}, [specialId]);
|
}, [specialId]);
|
||||||
|
|
||||||
const fetchSpecial = async () => {
|
const handleSaveStartTime = async (songId: number, startTime: number) => {
|
||||||
try {
|
await fetch(`/api/specials/${specialId}/songs`, {
|
||||||
const res = await fetch(`/api/specials/${specialId}`);
|
method: 'PUT',
|
||||||
if (res.ok) {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const data = await res.json();
|
body: JSON.stringify({ songId, startTime }),
|
||||||
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 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 (loading) {
|
if (loading) {
|
||||||
@@ -117,116 +60,16 @@ export default function SpecialEditorPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId);
|
|
||||||
const unlockSteps = JSON.parse(special.unlockSteps);
|
|
||||||
const totalDuration = unlockSteps[unlockSteps.length - 1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
<CurateSpecialEditor
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
special={special}
|
||||||
<button
|
locale={locale}
|
||||||
onClick={() => router.push('/admin')}
|
onBack={() => router.push('/admin')}
|
||||||
style={{
|
onSaveStartTime={handleSaveStartTime}
|
||||||
padding: '0.5rem 1rem',
|
backLabel="← Back to Admin"
|
||||||
background: '#e5e7eb',
|
headerPrefix="Edit Special:"
|
||||||
border: 'none',
|
noSongsSubHint="Go back to the admin dashboard to add songs to this special."
|
||||||
borderRadius: '0.5rem',
|
/>
|
||||||
cursor: 'pointer',
|
|
||||||
marginBottom: '1rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
← Back to Admin
|
|
||||||
</button>
|
|
||||||
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
|
||||||
Edit Special: {special.name}
|
|
||||||
</h1>
|
|
||||||
{special.subtitle && (
|
|
||||||
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
|
|
||||||
{special.subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p style={{ color: '#666', marginTop: '0.5rem' }}>
|
|
||||||
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{special.songs.length === 0 ? (
|
|
||||||
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
|
|
||||||
<p>No songs assigned to this special yet.</p>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
|
||||||
Go back to the admin dashboard to add songs to this special.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
|
||||||
Select Song to Curate
|
|
||||||
</h2>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
|
|
||||||
{special.songs.map(ss => (
|
|
||||||
<div
|
|
||||||
key={ss.songId}
|
|
||||||
onClick={() => setSelectedSongId(ss.songId)}
|
|
||||||
style={{
|
|
||||||
padding: '1rem',
|
|
||||||
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
|
|
||||||
color: selectedSongId === ss.songId ? 'white' : 'black',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
|
|
||||||
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
|
|
||||||
Start: {ss.startTime}s
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedSpecialSong && (
|
|
||||||
<div>
|
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
|
||||||
Curate: {selectedSpecialSong.song.title}
|
|
||||||
</h2>
|
|
||||||
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
|
|
||||||
Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!hasUnsavedChanges || saving}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1.5rem',
|
|
||||||
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
|
|
||||||
color: hasUnsavedChanges ? 'white' : '#9ca3af',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saving ? '💾 Saving...' : hasUnsavedChanges ? '💾 Save Changes' : '✓ Saved'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<WaveformEditor
|
|
||||||
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
|
|
||||||
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
|
|
||||||
duration={totalDuration}
|
|
||||||
unlockSteps={unlockSteps}
|
|
||||||
onStartTimeChange={handleStartTimeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -807,6 +807,25 @@ export default function CuratorPageClient() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<Link
|
||||||
|
href="/curator/specials"
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✨ {t('curateSpecialsButton')}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/curator/help"
|
href="/curator/help"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -274,7 +274,25 @@
|
|||||||
"noBatchOperations": "Keine Batch-Operationen angegeben",
|
"noBatchOperations": "Keine Batch-Operationen angegeben",
|
||||||
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
|
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
|
||||||
"batchUpdateError": "Fehler: {error}",
|
"batchUpdateError": "Fehler: {error}",
|
||||||
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung"
|
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung",
|
||||||
|
"backToDashboard": "Zurück zum Dashboard",
|
||||||
|
"curateSpecialsButton": "Specials kuratieren",
|
||||||
|
"curateSpecialsTitle": "Deine Specials kuratieren",
|
||||||
|
"curateSpecialsDescription": "Hier kannst du die Startzeiten der Songs in deinen zugewiesenen Specials für das Rätsel feinjustieren.",
|
||||||
|
"noSpecialPermissions": "Dir sind keine Specials zugeordnet.",
|
||||||
|
"noSpecialsInScope": "Keine Specials zum Kuratieren vorhanden.",
|
||||||
|
"curateSpecialSongCount": "{count, plural, one {# Song} other {# Songs}} in diesem Special",
|
||||||
|
"curateSpecialOpen": "Öffnen",
|
||||||
|
"specialForbidden": "Du darfst dieses Special nicht bearbeiten.",
|
||||||
|
"specialNotFound": "Special nicht gefunden.",
|
||||||
|
"backToCuratorSpecials": "Zurück zur Special-Übersicht",
|
||||||
|
"curateSpecialHeaderPrefix": "Special kuratieren:",
|
||||||
|
"curateSpecialNoSongs": "Diesem Special sind noch keine Songs zugeordnet.",
|
||||||
|
"curateSpecialNoSongsSub": "Gehe zurück zu deinem Dashboard, um diesem Special Songs zuzuordnen.",
|
||||||
|
"curateSpecialInstructions": "Klicke auf die Waveform, um zu wählen, wo das Rätsel starten soll. Der hervorgehobene Bereich zeigt, was die Spieler hören.",
|
||||||
|
"saving": "💾 Speichere...",
|
||||||
|
"saveChanges": "💾 Änderungen speichern",
|
||||||
|
"saved": "✓ Gespeichert"
|
||||||
},
|
},
|
||||||
"CuratorHelp": {
|
"CuratorHelp": {
|
||||||
"title": "Kurator-Hilfe & Handbuch",
|
"title": "Kurator-Hilfe & Handbuch",
|
||||||
|
|||||||
@@ -274,7 +274,25 @@
|
|||||||
"noBatchOperations": "No batch operations specified",
|
"noBatchOperations": "No batch operations specified",
|
||||||
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
|
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
|
||||||
"batchUpdateError": "Error: {error}",
|
"batchUpdateError": "Error: {error}",
|
||||||
"batchUpdateNetworkError": "Network error during batch update"
|
"batchUpdateNetworkError": "Network error during batch update",
|
||||||
|
"backToDashboard": "Back to dashboard",
|
||||||
|
"curateSpecialsButton": "Curate Specials",
|
||||||
|
"curateSpecialsTitle": "Curate your Specials",
|
||||||
|
"curateSpecialsDescription": "Here you can fine-tune the start times of the songs in your assigned specials for the puzzle.",
|
||||||
|
"noSpecialPermissions": "You do not have any specials assigned to you.",
|
||||||
|
"noSpecialsInScope": "No specials available for you to curate.",
|
||||||
|
"curateSpecialSongCount": "{count, plural, one {# song} other {# songs}} in this special",
|
||||||
|
"curateSpecialOpen": "Open",
|
||||||
|
"specialForbidden": "You are not allowed to edit this special.",
|
||||||
|
"specialNotFound": "Special not found.",
|
||||||
|
"backToCuratorSpecials": "Back to specials overview",
|
||||||
|
"curateSpecialHeaderPrefix": "Curate Special:",
|
||||||
|
"curateSpecialNoSongs": "No songs assigned to this special yet.",
|
||||||
|
"curateSpecialNoSongsSub": "Go back to your dashboard to add songs to this special.",
|
||||||
|
"curateSpecialInstructions": "Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.",
|
||||||
|
"saving": "💾 Saving...",
|
||||||
|
"saveChanges": "💾 Save Changes",
|
||||||
|
"saved": "✓ Saved"
|
||||||
},
|
},
|
||||||
"CuratorHelp": {
|
"CuratorHelp": {
|
||||||
"title": "Curator Help & Manual",
|
"title": "Curator Help & Manual",
|
||||||
|
|||||||
Reference in New Issue
Block a user