From b46e9e3882402256eec12c9d03e9fa7c1c366d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Thu, 4 Dec 2025 12:27:08 +0100 Subject: [PATCH] Add curator special curation flow and shared editor --- app/[locale]/admin/specials/[id]/page.tsx | 7 + app/[locale]/curator/specials/[id]/page.tsx | 7 + app/[locale]/curator/specials/page.tsx | 7 + app/admin/specials/[id]/page.tsx | 233 +++---------------- app/api/curator/specials/[id]/route.ts | 58 +++++ app/api/curator/specials/[id]/songs/route.ts | 69 ++++++ app/api/curator/specials/route.ts | 47 ++++ app/curator/CuratorPageClient.tsx | 19 ++ app/curator/specials/[id]/page.tsx | 137 +++++++++++ app/curator/specials/page.tsx | 152 ++++++++++++ components/CurateSpecialEditor.tsx | 212 +++++++++++++++++ lib/curatorAuth.ts | 17 ++ messages/de.json | 20 +- messages/en.json | 20 +- 14 files changed, 808 insertions(+), 197 deletions(-) create mode 100644 app/[locale]/admin/specials/[id]/page.tsx create mode 100644 app/[locale]/curator/specials/[id]/page.tsx create mode 100644 app/[locale]/curator/specials/page.tsx create mode 100644 app/api/curator/specials/[id]/route.ts create mode 100644 app/api/curator/specials/[id]/songs/route.ts create mode 100644 app/api/curator/specials/route.ts create mode 100644 app/curator/specials/[id]/page.tsx create mode 100644 app/curator/specials/page.tsx create mode 100644 components/CurateSpecialEditor.tsx create mode 100644 lib/curatorAuth.ts diff --git a/app/[locale]/admin/specials/[id]/page.tsx b/app/[locale]/admin/specials/[id]/page.tsx new file mode 100644 index 0000000..fa62ed3 --- /dev/null +++ b/app/[locale]/admin/specials/[id]/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import SpecialEditorPage from '@/app/admin/specials/[id]/page'; + +export default SpecialEditorPage; + + diff --git a/app/[locale]/curator/specials/[id]/page.tsx b/app/[locale]/curator/specials/[id]/page.tsx new file mode 100644 index 0000000..7d29562 --- /dev/null +++ b/app/[locale]/curator/specials/[id]/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import CuratorSpecialEditorPage from '@/app/curator/specials/[id]/page'; + +export default CuratorSpecialEditorPage; + + diff --git a/app/[locale]/curator/specials/page.tsx b/app/[locale]/curator/specials/page.tsx new file mode 100644 index 0000000..b605487 --- /dev/null +++ b/app/[locale]/curator/specials/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import CuratorSpecialsPage from '@/app/curator/specials/page'; + +export default CuratorSpecialsPage; + + diff --git a/app/admin/specials/[id]/page.tsx b/app/admin/specials/[id]/page.tsx index 1dc3cb2..8fa671e 100644 --- a/app/admin/specials/[id]/page.tsx +++ b/app/admin/specials/[id]/page.tsx @@ -1,103 +1,46 @@ '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(null); - const [selectedSongId, setSelectedSongId] = useState(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(null); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [pendingStartTime, setPendingStartTime] = useState(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 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); - } + const handleSaveStartTime = async (songId: number, startTime: number) => { + await fetch(`/api/specials/${specialId}/songs`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ songId, startTime }), + }); }; 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 ( -
-
- -

- Edit Special: {special.name} -

- {special.subtitle && ( -

- {special.subtitle} -

- )} -

- Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s -

-
- - {special.songs.length === 0 ? ( -
-

No songs assigned to this special yet.

-

- Go back to the admin dashboard to add songs to this special. -

-
- ) : ( -
-
-

- Select Song to Curate -

-
- {special.songs.map(ss => ( -
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' - }} - > -
{ss.song.title}
-
{ss.song.artist}
-
- Start: {ss.startTime}s -
-
- ))} -
-
- - {selectedSpecialSong && ( -
-

- Curate: {selectedSpecialSong.song.title} -

-
-
-

- Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear. -

- -
- -
-
- )} -
- )} -
+ 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." + /> ); } + diff --git a/app/api/curator/specials/[id]/route.ts b/app/api/curator/specials/[id]/route.ts new file mode 100644 index 0000000..a4c7a90 --- /dev/null +++ b/app/api/curator/specials/[id]/route.ts @@ -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); +} + + diff --git a/app/api/curator/specials/[id]/songs/route.ts b/app/api/curator/specials/[id]/songs/route.ts new file mode 100644 index 0000000..cd3a53d --- /dev/null +++ b/app/api/curator/specials/[id]/songs/route.ts @@ -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 }); + } +} + + diff --git a/app/api/curator/specials/route.ts b/app/api/curator/specials/route.ts new file mode 100644 index 0000000..3d57f5b --- /dev/null +++ b/app/api/curator/specials/route.ts @@ -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); +} + + diff --git a/app/curator/CuratorPageClient.tsx b/app/curator/CuratorPageClient.tsx index b6ef372..fd648a5 100644 --- a/app/curator/CuratorPageClient.tsx +++ b/app/curator/CuratorPageClient.tsx @@ -807,6 +807,25 @@ export default function CuratorPageClient() { )}
+ + ✨ {t('curateSpecialsButton')} + (null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

{t('loadingData')}

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + if (!special) { + return ( +
+

{t('specialNotFound')}

+ +
+ ); + } + + return ( + 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')} + /> + ); +} + + diff --git a/app/curator/specials/page.tsx b/app/curator/specials/page.tsx new file mode 100644 index 0000000..3323f0a --- /dev/null +++ b/app/curator/specials/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

{t('loadingData')}

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (specials.length === 0) { + return ( +
+

{t('noSpecialsInScope')}

+
+ + {t('backToDashboard')} + +
+
+ ); + } + + 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 ( +
+
+

+ {t('curateSpecialsTitle')} +

+ + {t('backToDashboard')} + +
+

+ {t('curateSpecialsDescription')} +

+
+ {specials.map(special => ( + +
+

+ {resolveLocalized(special.name, String(locale))} +

+

+ {t('curateSpecialSongCount', { count: special.songCount })} +

+
+
+ {t('curateSpecialOpen')} +
+ + ))} +
+
+ ); +} + + diff --git a/components/CurateSpecialEditor.tsx b/components/CurateSpecialEditor.tsx new file mode 100644 index 0000000..397bf1f --- /dev/null +++ b/components/CurateSpecialEditor.tsx @@ -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; + 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( + special.songs.length > 0 ? special.songs[0].songId : null + ); + const [pendingStartTime, setPendingStartTime] = useState( + 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 ( +
+
+ +

+ {headerPrefix} {specialName} +

+ {specialSubtitle && ( +

+ {specialSubtitle} +

+ )} +

+ Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s +

+
+ + {special.songs.length === 0 ? ( +
+

{noSongsHint}

+

+ {noSongsSubHint} +

+
+ ) : ( +
+
+

+ Select Song to Curate +

+
+ {special.songs.map(ss => ( +
{ + 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' + }} + > +
{ss.song.title}
+
{ss.song.artist}
+
+ Start: {ss.startTime}s +
+
+ ))} +
+
+ + {selectedSpecialSong && ( +
+

+ Curate: {selectedSpecialSong.song.title} +

+
+
+

+ {instructionsText} +

+ +
+ +
+
+ )} +
+ )} +
+ ); +} + + diff --git a/lib/curatorAuth.ts b/lib/curatorAuth.ts new file mode 100644 index 0000000..8b719d0 --- /dev/null +++ b/lib/curatorAuth.ts @@ -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, + }; +} + + diff --git a/messages/de.json b/messages/de.json index d5557c0..568ac58 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index c6febbf..d111146 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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",