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:
parent
86874daf57
commit
2c570febd1
2 changed files with 194 additions and 35 deletions
|
|
@ -1223,6 +1223,22 @@ const soundboardPlugin: Plugin = {
|
||||||
if (err) { res.status(400).json({ error: err?.message ?? 'Upload fehlgeschlagen' }); return; }
|
if (err) { res.status(400).json({ error: err?.message ?? 'Upload fehlgeschlagen' }); return; }
|
||||||
const files = (req as any).files as any[] | undefined;
|
const files = (req as any).files as any[] | undefined;
|
||||||
if (!files?.length) { res.status(400).json({ error: 'Keine gültigen Dateien' }); return; }
|
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(() => {}); }
|
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 })) });
|
res.json({ ok: true, files: files.map(f => ({ name: f.filename, size: f.size })) });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
CONSTANTS
|
||||||
══════════════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
@ -365,6 +395,13 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const uploadDismissRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
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 ── */
|
/* ── Voice Stats ── */
|
||||||
const [voiceStats, setVoiceStats] = useState<VoiceStats | null>(null);
|
const [voiceStats, setVoiceStats] = useState<VoiceStats | null>(null);
|
||||||
const [showConnModal, setShowConnModal] = useState(false);
|
const [showConnModal, setShowConnModal] = useState(false);
|
||||||
|
|
@ -687,45 +724,68 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
notify('Admin-Login erforderlich zum Hochladen', 'error');
|
notify('Admin-Login erforderlich zum Hochladen', 'error');
|
||||||
return;
|
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 => ({
|
async function handleDropConfirm() {
|
||||||
id: Math.random().toString(36).slice(2),
|
if (dropFiles.length === 0) return;
|
||||||
file: f,
|
const file = dropFiles[dropIndex];
|
||||||
status: 'waiting',
|
const name = dropName.trim() || file.name.replace(/\.(mp3|wav)$/i, '');
|
||||||
progress: 0,
|
|
||||||
}));
|
|
||||||
setUploads(items);
|
|
||||||
setShowUploads(true);
|
|
||||||
|
|
||||||
const updated = [...items];
|
setDropPhase('uploading');
|
||||||
for (let i = 0; i < updated.length; i++) {
|
setDropProgress(0);
|
||||||
updated[i] = { ...updated[i], status: 'uploading' };
|
|
||||||
setUploads([...updated]);
|
try {
|
||||||
try {
|
await apiUploadFileWithName(file, name, pct => setDropProgress(pct));
|
||||||
const savedName = await apiUploadFile(
|
setDropPhase('done');
|
||||||
updated[i].file,
|
|
||||||
pct => {
|
// After short delay, move to next file or close
|
||||||
updated[i] = { ...updated[i], progress: pct };
|
setTimeout(() => {
|
||||||
setUploads([...updated]);
|
const nextIdx = dropIndex + 1;
|
||||||
},
|
if (nextIdx < dropFiles.length) {
|
||||||
);
|
setDropIndex(nextIdx);
|
||||||
updated[i] = { ...updated[i], status: 'done', progress: 100, savedName };
|
const nextBase = dropFiles[nextIdx].name.replace(/\.(mp3|wav)$/i, '');
|
||||||
} catch (e: any) {
|
setDropName(nextBase);
|
||||||
updated[i] = { ...updated[i], status: 'error', error: e?.message ?? 'Fehler' };
|
setDropPhase('naming');
|
||||||
}
|
setDropProgress(0);
|
||||||
setUploads([...updated]);
|
} 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
|
function handleDropSkip() {
|
||||||
setRefreshKey(k => k + 1);
|
const nextIdx = dropIndex + 1;
|
||||||
void loadAnalytics();
|
if (nextIdx < dropFiles.length) {
|
||||||
|
setDropIndex(nextIdx);
|
||||||
// Auto-dismiss after 3.5s
|
const nextBase = dropFiles[nextIdx].name.replace(/\.(mp3|wav)$/i, '');
|
||||||
uploadDismissRef.current = setTimeout(() => {
|
setDropName(nextBase);
|
||||||
setShowUploads(false);
|
setDropPhase('naming');
|
||||||
setUploads([]);
|
setDropProgress(0);
|
||||||
}, 3500);
|
} else {
|
||||||
|
setDropFiles([]);
|
||||||
|
if (dropIndex > 0) {
|
||||||
|
setRefreshKey(k => k + 1);
|
||||||
|
void loadAnalytics();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStop() {
|
async function handleStop() {
|
||||||
|
|
@ -1582,6 +1642,89 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
</div>
|
</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 ── */}
|
{/* ── Download Modal ── */}
|
||||||
{dlModal && (
|
{dlModal && (
|
||||||
<div className="dl-modal-overlay" onClick={() => dlModal.phase !== 'downloading' && setDlModal(null)}>
|
<div className="dl-modal-overlay" onClick={() => dlModal.phase !== 'downloading' && setDlModal(null)}>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue