Feat: Drag & Drop MP3/WAV Upload mit Progress-Tracking

Backend:
- multer reaktiviert (war auskommentiert) mit diskStorage + Collision-Handling
- /api/upload (POST, admin-protected): bis zu 20 Dateien gleichzeitig
- MP3/WAV-Filter (50MB Limit), sofortige Hintergrund-Normalisierung nach Upload

Frontend:
- Globale window dragenter/dragleave/drop Listener mit Counter gegen false-positives
- Drag-Overlay: Vollbild-Blur + animierter Drop-Zone (pulsierender Accent-Border, bouncing Icon)
- Upload-Queue: floating Card bottom-right mit Per-Datei Progressbar + Status-Icons
  (sync-Animation beim Hochladen, check_circle grün, error rot)
- Auto-Refresh der Soundliste + Analytics nach Upload
- Auto-Dismiss der Queue nach 3.5s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bot 2026-03-01 22:15:07 +01:00
parent a61663166f
commit 52c86240af
4 changed files with 426 additions and 3 deletions

View file

@ -3,7 +3,7 @@ import {
fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume,
adminStatus, adminLogin, adminLogout, adminDelete, adminRename,
fetchCategories, partyStart, partyStop, subscribeEvents,
getSelectedChannels, setSelectedChannel,
getSelectedChannels, setSelectedChannel, uploadFile,
} from './api';
import type { VoiceChannelInfo, Sound, Category, AnalyticsResponse } from './types';
import { getCookie, setCookie } from './cookies';
@ -24,6 +24,15 @@ const CAT_PALETTE = [
type Tab = 'all' | 'favorites' | 'recent';
type UploadItem = {
id: string;
file: File;
status: 'waiting' | 'uploading' | 'done' | 'error';
progress: number;
savedName?: string;
error?: string;
};
export default function App() {
/* ── Data ── */
const [sounds, setSounds] = useState<Sound[]>([]);
@ -75,6 +84,13 @@ export default function App() {
const [renameTarget, setRenameTarget] = useState('');
const [renameValue, setRenameValue] = useState('');
/* ── Drag & Drop Upload ── */
const [isDragging, setIsDragging] = useState(false);
const [uploads, setUploads] = useState<UploadItem[]>([]);
const [showUploads, setShowUploads] = useState(false);
const dragCounterRef = useRef(0);
const uploadDismissRef = useRef<ReturnType<typeof setTimeout>>();
/* ── UI ── */
const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null);
const [clock, setClock] = useState('');
@ -85,6 +101,41 @@ export default function App() {
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
useEffect(() => { selectedRef.current = selected; }, [selected]);
/* ── Drag & Drop: globale Window-Listener ── */
useEffect(() => {
const onDragEnter = (e: DragEvent) => {
if (Array.from(e.dataTransfer?.items ?? []).some(i => i.kind === 'file')) {
dragCounterRef.current++;
setIsDragging(true);
}
};
const onDragLeave = () => {
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0) setIsDragging(false);
};
const onDragOver = (e: DragEvent) => e.preventDefault();
const onDrop = (e: DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0;
setIsDragging(false);
const files = Array.from(e.dataTransfer?.files ?? []).filter(f =>
/\.(mp3|wav)$/i.test(f.name)
);
if (files.length) handleFileDrop(files);
};
window.addEventListener('dragenter', onDragEnter);
window.addEventListener('dragleave', onDragLeave);
window.addEventListener('dragover', onDragOver);
window.addEventListener('drop', onDrop);
return () => {
window.removeEventListener('dragenter', onDragEnter);
window.removeEventListener('dragleave', onDragLeave);
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('drop', onDrop);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin]);
/* ── Helpers ── */
const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => {
setNotification({ msg, type });
@ -287,6 +338,52 @@ export default function App() {
}
}
async function handleFileDrop(files: File[]) {
if (!isAdmin) {
notify('Admin-Login erforderlich zum Hochladen', 'error');
return;
}
if (uploadDismissRef.current) clearTimeout(uploadDismissRef.current);
const items: UploadItem[] = files.map(f => ({
id: Math.random().toString(36).slice(2),
file: f,
status: 'waiting',
progress: 0,
}));
setUploads(items);
setShowUploads(true);
const updated = [...items];
for (let i = 0; i < updated.length; i++) {
updated[i] = { ...updated[i], status: 'uploading' };
setUploads([...updated]);
try {
const savedName = await uploadFile(
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]);
}
// Sound-Liste aktualisieren
setRefreshKey(k => k + 1);
void loadAnalytics();
// Auto-Dismiss nach 3s
uploadDismissRef.current = setTimeout(() => {
setShowUploads(false);
setUploads([]);
}, 3500);
}
async function handleStop() {
if (!selected) return;
setLastPlayed('');
@ -1013,6 +1110,58 @@ export default function App() {
</div>
</div>
)}
{/* ── Drag & Drop Overlay ── */}
{isDragging && (
<div className="drop-overlay">
<div className="drop-zone">
<span className="material-icons drop-icon">cloud_upload</span>
<div className="drop-title">MP3 & WAV hier ablegen</div>
<div className="drop-sub">Mehrere Dateien gleichzeitig möglich</div>
</div>
</div>
)}
{/* ── Upload-Queue ── */}
{showUploads && uploads.length > 0 && (
<div className="upload-queue">
<div className="uq-header">
<span className="material-icons" style={{ fontSize: 16 }}>upload</span>
<span>
{uploads.every(u => u.status === 'done' || u.status === 'error')
? `${uploads.filter(u => u.status === 'done').length} von ${uploads.length} hochgeladen`
: `Lade hoch… (${uploads.filter(u => u.status === 'done').length}/${uploads.length})`}
</span>
<button className="uq-close" onClick={() => { setShowUploads(false); setUploads([]); }}>
<span className="material-icons" style={{ fontSize: 14 }}>close</span>
</button>
</div>
<div className="uq-list">
{uploads.map(u => (
<div key={u.id} className={`uq-item uq-${u.status}`}>
<span className="material-icons uq-file-icon">audio_file</span>
<div className="uq-info">
<div className="uq-name" title={u.savedName ?? u.file.name}>
{u.savedName ?? u.file.name}
</div>
<div className="uq-size">{(u.file.size / 1024).toFixed(0)} KB</div>
</div>
{(u.status === 'waiting' || u.status === 'uploading') && (
<div className="uq-progress-wrap">
<div className="uq-progress-bar" style={{ width: `${u.progress}%` }} />
</div>
)}
<span className={`material-icons uq-status-icon uq-status-${u.status}`}>
{u.status === 'done' ? 'check_circle' :
u.status === 'error' ? 'error' :
u.status === 'uploading' ? 'sync' : 'schedule'}
</span>
{u.status === 'error' && <div className="uq-error">{u.error}</div>}
</div>
))}
</div>
</div>
)}
</div>
);
}