Files
hoerdle/components/CurateSpecialEditor.tsx
Hördle Bot 989654f62e Fix: Waveform-Editor verwendet jetzt API-Route statt statischen Pfad
- WaveformEditor verwendet /api/audio/... statt /uploads/...
- Gleicher Pfad wie beim Abspielen aus der Liste
- Behebt Problem, dass neu hochgeladene Dateien nicht im Waveform-Editor bearbeitbar waren
2025-12-07 13:16:32 +01:00

209 lines
9.3 KiB
TypeScript

'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) {
// Filtere Songs ohne vollständige Song-Daten (song, song.filename)
const validSongs = special.songs.filter(ss => ss.song && ss.song.filename);
const [selectedSongId, setSelectedSongId] = useState<number | null>(
validSongs.length > 0 ? validSongs[0].songId : null
);
const [pendingStartTime, setPendingStartTime] = useState<number | null>(
validSongs.length > 0 ? validSongs[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 = validSongs.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' }}>
<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>
{validSongs.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' }}>
{validSongs.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 && selectedSpecialSong.song && selectedSpecialSong.song.filename ? (
<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={`/api/audio/${selectedSpecialSong.song.filename}`}
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
duration={totalDuration}
unlockSteps={unlockSteps}
onStartTimeChange={handleStartTimeChange}
/>
</div>
</div>
) : selectedSpecialSong ? (
<div style={{ padding: '2rem', background: '#fee2e2', borderRadius: '0.5rem', textAlign: 'center' }}>
<p style={{ color: '#991b1b', fontWeight: 'bold' }}>
Fehler: Song-Daten unvollständig. Bitte wählen Sie einen anderen Song.
</p>
</div>
) : null}
</div>
)}
</div>
);
}