Soundboard: Drag & Drop mit Rename-Modal

- MP3/WAV per Drag & Drop ablegen öffnet jetzt ein Rename-Modal
- Jede Datei einzeln benennen vor dem Upload (sequentiell bei mehreren)
- Server-Upload-Endpoint unterstützt optionalen customName Parameter
- Reuse der bestehenden dl-modal CSS-Klassen für konsistentes Design
- Überspringen-Option bei mehreren Dateien

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-08 19:11:16 +01:00
parent 86874daf57
commit 2c570febd1
2 changed files with 194 additions and 35 deletions

View file

@ -1223,6 +1223,22 @@ const soundboardPlugin: Plugin = {
if (err) { res.status(400).json({ error: err?.message ?? 'Upload fehlgeschlagen' }); return; }
const files = (req as any).files as any[] | undefined;
if (!files?.length) { res.status(400).json({ error: 'Keine gültigen Dateien' }); return; }
// If a customName was provided, rename the first file
const customName = req.body?.customName?.trim();
if (customName && files.length === 1) {
const sanitized = customName.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const newFilename = sanitized.endsWith('.mp3') || sanitized.endsWith('.wav')
? sanitized : `${sanitized}.mp3`;
const oldPath = files[0].path;
const newPath = path.join(path.dirname(oldPath), newFilename);
if (!fs.existsSync(newPath)) {
fs.renameSync(oldPath, newPath);
files[0].filename = newFilename;
files[0].path = newPath;
}
}
if (NORMALIZE_ENABLE) { for (const f of files) normalizeToCache(f.path).catch(() => {}); }
res.json({ ok: true, files: files.map(f => ({ name: f.filename, size: f.size })) });
});

View file

@ -251,6 +251,36 @@ function apiUploadFile(
});
}
function apiUploadFileWithName(
file: File,
customName: string,
onProgress: (pct: number) => void,
): Promise<string> {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append('files', file);
if (customName) form.append('customName', customName);
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/upload`);
xhr.upload.onprogress = e => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status === 200) {
try {
const data = JSON.parse(xhr.responseText);
resolve(data.files?.[0]?.name ?? file.name);
} catch { resolve(file.name); }
} else {
try { reject(new Error(JSON.parse(xhr.responseText).error)); }
catch { reject(new Error(`HTTP ${xhr.status}`)); }
}
};
xhr.onerror = () => reject(new Error('Netzwerkfehler'));
xhr.send(form);
});
}
/*
CONSTANTS
*/
@ -365,6 +395,13 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
const dragCounterRef = useRef(0);
const uploadDismissRef = useRef<ReturnType<typeof setTimeout>>(undefined);
/* ── Drop Rename Modal ── */
const [dropFiles, setDropFiles] = useState<File[]>([]);
const [dropIndex, setDropIndex] = useState(0);
const [dropName, setDropName] = useState('');
const [dropPhase, setDropPhase] = useState<'naming' | 'uploading' | 'done'>('naming');
const [dropProgress, setDropProgress] = useState(0);
/* ── Voice Stats ── */
const [voiceStats, setVoiceStats] = useState<VoiceStats | null>(null);
const [showConnModal, setShowConnModal] = useState(false);
@ -687,45 +724,68 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
notify('Admin-Login erforderlich zum Hochladen', 'error');
return;
}
if (uploadDismissRef.current) clearTimeout(uploadDismissRef.current);
if (files.length === 0) return;
// Open rename modal for first file
setDropFiles(files);
setDropIndex(0);
const baseName = files[0].name.replace(/\.(mp3|wav)$/i, '');
setDropName(baseName);
setDropPhase('naming');
setDropProgress(0);
}
const items: UploadItem[] = files.map(f => ({
id: Math.random().toString(36).slice(2),
file: f,
status: 'waiting',
progress: 0,
}));
setUploads(items);
setShowUploads(true);
async function handleDropConfirm() {
if (dropFiles.length === 0) return;
const file = dropFiles[dropIndex];
const name = dropName.trim() || file.name.replace(/\.(mp3|wav)$/i, '');
const updated = [...items];
for (let i = 0; i < updated.length; i++) {
updated[i] = { ...updated[i], status: 'uploading' };
setUploads([...updated]);
try {
const savedName = await apiUploadFile(
updated[i].file,
pct => {
updated[i] = { ...updated[i], progress: pct };
setUploads([...updated]);
},
);
updated[i] = { ...updated[i], status: 'done', progress: 100, savedName };
} catch (e: any) {
updated[i] = { ...updated[i], status: 'error', error: e?.message ?? 'Fehler' };
}
setUploads([...updated]);
setDropPhase('uploading');
setDropProgress(0);
try {
await apiUploadFileWithName(file, name, pct => setDropProgress(pct));
setDropPhase('done');
// After short delay, move to next file or close
setTimeout(() => {
const nextIdx = dropIndex + 1;
if (nextIdx < dropFiles.length) {
setDropIndex(nextIdx);
const nextBase = dropFiles[nextIdx].name.replace(/\.(mp3|wav)$/i, '');
setDropName(nextBase);
setDropPhase('naming');
setDropProgress(0);
} else {
// All done - close modal and refresh
setDropFiles([]);
setDropIndex(0);
setDropName('');
setRefreshKey(k => k + 1);
void loadAnalytics();
notify(`${dropFiles.length} Sound${dropFiles.length > 1 ? 's' : ''} hochgeladen`, 'info');
}
}, 800);
} catch (e: any) {
notify(e?.message || 'Upload fehlgeschlagen', 'error');
setDropFiles([]);
}
}
// Refresh sound list
setRefreshKey(k => k + 1);
void loadAnalytics();
// Auto-dismiss after 3.5s
uploadDismissRef.current = setTimeout(() => {
setShowUploads(false);
setUploads([]);
}, 3500);
function handleDropSkip() {
const nextIdx = dropIndex + 1;
if (nextIdx < dropFiles.length) {
setDropIndex(nextIdx);
const nextBase = dropFiles[nextIdx].name.replace(/\.(mp3|wav)$/i, '');
setDropName(nextBase);
setDropPhase('naming');
setDropProgress(0);
} else {
setDropFiles([]);
if (dropIndex > 0) {
setRefreshKey(k => k + 1);
void loadAnalytics();
}
}
}
async function handleStop() {
@ -1582,6 +1642,89 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
</div>
)}
{/* ── Drop Rename Modal ── */}
{dropFiles.length > 0 && (
<div className="dl-modal-overlay" onClick={() => dropPhase === 'naming' && handleDropSkip()}>
<div className="dl-modal" onClick={e => e.stopPropagation()}>
<div className="dl-modal-header">
<span className="material-icons" style={{ fontSize: 20 }}>upload_file</span>
<span>
{dropPhase === 'naming' ? 'Sound benennen'
: dropPhase === 'uploading' ? 'Wird hochgeladen...'
: 'Gespeichert!'}
{dropFiles.length > 1 && ` (${dropIndex + 1}/${dropFiles.length})`}
</span>
{dropPhase === 'naming' && (
<button className="dl-modal-close" onClick={handleDropSkip}>
<span className="material-icons" style={{ fontSize: 16 }}>close</span>
</button>
)}
</div>
<div className="dl-modal-body">
{/* File info badge */}
<div className="dl-modal-url">
<span className="dl-modal-tag mp3">Datei</span>
<span className="dl-modal-url-text" title={dropFiles[dropIndex]?.name}>
{dropFiles[dropIndex]?.name}
</span>
<span style={{ marginLeft: 'auto', opacity: .5, fontSize: 12 }}>
{((dropFiles[dropIndex]?.size ?? 0) / 1024).toFixed(0)} KB
</span>
</div>
{/* Naming phase */}
{dropPhase === 'naming' && (
<div className="dl-modal-field">
<label className="dl-modal-label">Dateiname</label>
<div className="dl-modal-input-wrap">
<input
className="dl-modal-input"
type="text"
placeholder="Dateiname eingeben..."
value={dropName}
onChange={e => setDropName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void handleDropConfirm(); }}
autoFocus
/>
<span className="dl-modal-ext">.mp3</span>
</div>
</div>
)}
{/* Uploading phase */}
{dropPhase === 'uploading' && (
<div className="dl-modal-progress">
<div className="dl-modal-spinner" />
<span>Upload: {dropProgress}%</span>
</div>
)}
{/* Done phase */}
{dropPhase === 'done' && (
<div className="dl-modal-success">
<span className="material-icons dl-modal-check">check_circle</span>
<span>Erfolgreich hochgeladen!</span>
</div>
)}
</div>
{/* Actions */}
{dropPhase === 'naming' && (
<div className="dl-modal-actions">
<button className="dl-modal-cancel" onClick={handleDropSkip}>
{dropFiles.length > 1 ? 'Überspringen' : 'Abbrechen'}
</button>
<button className="dl-modal-submit" onClick={() => void handleDropConfirm()}>
<span className="material-icons" style={{ fontSize: 16 }}>upload</span>
Hochladen
</button>
</div>
)}
</div>
</div>
)}
{/* ── Download Modal ── */}
{dlModal && (
<div className="dl-modal-overlay" onClick={() => dlModal.phase !== 'downloading' && setDlModal(null)}>