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:
parent
a61663166f
commit
52c86240af
4 changed files with 426 additions and 3 deletions
151
web/src/App.tsx
151
web/src/App.tsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue