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

@ -2,7 +2,7 @@ import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import express, { Request, Response } from 'express'; import express, { Request, Response } from 'express';
// import multer from 'multer'; import multer from 'multer';
import cors from 'cors'; import cors from 'cors';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js'; import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js';
@ -1029,6 +1029,47 @@ app.post('/api/admin/sounds/rename', requireAdmin, (req: Request, res: Response)
} }
}); });
// --- Datei-Upload (Drag & Drop) ---
const uploadStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, SOUNDS_DIR),
filename: (_req, file, cb) => {
const safe = file.originalname.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const { name, ext } = path.parse(safe);
let finalName = safe;
let i = 2;
while (fs.existsSync(path.join(SOUNDS_DIR, finalName))) {
finalName = `${name}-${i}${ext}`;
i++;
}
cb(null, finalName);
},
});
const uploadMulter = multer({
storage: uploadStorage,
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, ext === '.mp3' || ext === '.wav');
},
limits: { fileSize: 50 * 1024 * 1024, files: 20 },
});
app.post('/api/upload', requireAdmin, (req: Request, res: Response) => {
uploadMulter.array('files', 20)(req, res, async (err) => {
if (err) return res.status(400).json({ error: err.message ?? 'Upload fehlgeschlagen' });
const files = (req as any).files as Express.Multer.File[] | undefined;
if (!files?.length) return res.status(400).json({ error: 'Keine gültigen Dateien (nur MP3/WAV)' });
const saved = files.map(f => ({ name: f.filename, size: f.size }));
// Normalisierung im Hintergrund starten
if (NORMALIZE_ENABLE) {
for (const f of files) {
normalizeToCache(f.path).catch(e => console.warn(`Norm after upload failed (${f.filename}):`, e));
}
}
console.log(`${new Date().toISOString()} | Upload: ${files.map(f => f.filename).join(', ')}`);
res.json({ ok: true, files: saved });
});
});
// --- Kategorien API --- // --- Kategorien API ---
app.get('/api/categories', (_req: Request, res: Response) => { app.get('/api/categories', (_req: Request, res: Response) => {
res.json({ categories: persistedState.categories ?? [] }); res.json({ categories: persistedState.categories ?? [] });

View file

@ -3,7 +3,7 @@ import {
fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume, fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume,
adminStatus, adminLogin, adminLogout, adminDelete, adminRename, adminStatus, adminLogin, adminLogout, adminDelete, adminRename,
fetchCategories, partyStart, partyStop, subscribeEvents, fetchCategories, partyStart, partyStop, subscribeEvents,
getSelectedChannels, setSelectedChannel, getSelectedChannels, setSelectedChannel, uploadFile,
} from './api'; } from './api';
import type { VoiceChannelInfo, Sound, Category, AnalyticsResponse } from './types'; import type { VoiceChannelInfo, Sound, Category, AnalyticsResponse } from './types';
import { getCookie, setCookie } from './cookies'; import { getCookie, setCookie } from './cookies';
@ -24,6 +24,15 @@ const CAT_PALETTE = [
type Tab = 'all' | 'favorites' | 'recent'; 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() { export default function App() {
/* ── Data ── */ /* ── Data ── */
const [sounds, setSounds] = useState<Sound[]>([]); const [sounds, setSounds] = useState<Sound[]>([]);
@ -75,6 +84,13 @@ export default function App() {
const [renameTarget, setRenameTarget] = useState(''); const [renameTarget, setRenameTarget] = useState('');
const [renameValue, setRenameValue] = 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 ── */ /* ── UI ── */
const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null); const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null);
const [clock, setClock] = useState(''); const [clock, setClock] = useState('');
@ -85,6 +101,41 @@ export default function App() {
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
useEffect(() => { selectedRef.current = selected; }, [selected]); 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 ── */ /* ── Helpers ── */
const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => { const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => {
setNotification({ msg, type }); 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() { async function handleStop() {
if (!selected) return; if (!selected) return;
setLastPlayed(''); setLastPlayed('');
@ -1013,6 +1110,58 @@ export default function App() {
</div> </div>
</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> </div>
); );
} }

View file

@ -206,7 +206,34 @@ export async function playUrl(url: string, guildId: string, channelId: string, v
} }
} }
// uploadFile removed (build reverted) /** Lädt eine einzelne MP3/WAV-Datei hoch. Gibt den gespeicherten Dateinamen zurück. */
export function uploadFile(
file: File,
onProgress: (pct: number) => void,
): Promise<string> {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append('files', file);
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);
});
}

View file

@ -1778,6 +1778,212 @@ input, select {
} }
} }
/*
Drag & Drop Overlay
*/
.drop-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .78);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
animation: fade-in 120ms ease;
pointer-events: none;
}
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 64px 72px;
border-radius: 24px;
border: 2.5px dashed rgba(var(--accent-rgb), .55);
background: rgba(var(--accent-rgb), .07);
animation: drop-pulse 2.2s ease-in-out infinite;
}
@keyframes drop-pulse {
0%, 100% {
border-color: rgba(var(--accent-rgb), .45);
box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0);
}
50% {
border-color: rgba(var(--accent-rgb), .9);
box-shadow: 0 0 60px 12px rgba(var(--accent-rgb), .12);
}
}
.drop-icon {
font-size: 64px;
color: var(--accent);
animation: drop-bounce 1.8s ease-in-out infinite;
}
@keyframes drop-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.drop-title {
font-size: 22px;
font-weight: 700;
color: var(--text-normal);
letter-spacing: -.3px;
}
.drop-sub {
font-size: 13px;
color: var(--text-muted);
}
/*
Upload Queue (floating card)
*/
.upload-queue {
position: fixed;
bottom: 24px;
right: 24px;
width: 340px;
background: var(--bg-secondary);
border: 1px solid rgba(255, 255, 255, .09);
border-radius: 14px;
box-shadow: 0 8px 40px rgba(0, 0, 0, .45);
z-index: 200;
overflow: hidden;
animation: slide-up 200ms cubic-bezier(.16,1,.3,1);
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.uq-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
background: rgba(var(--accent-rgb), .12);
border-bottom: 1px solid rgba(255, 255, 255, .06);
font-size: 13px;
font-weight: 600;
color: var(--text-normal);
}
.uq-header .material-icons { color: var(--accent); }
.uq-close {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,.06);
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition), color var(--transition);
}
.uq-close:hover { background: rgba(255,255,255,.14); color: var(--text-normal); }
.uq-list {
display: flex;
flex-direction: column;
max-height: 260px;
overflow-y: auto;
padding: 6px 0;
}
.uq-item {
display: grid;
grid-template-columns: 20px 1fr auto 18px;
align-items: center;
gap: 8px;
padding: 8px 14px;
position: relative;
}
.uq-item + .uq-item {
border-top: 1px solid rgba(255, 255, 255, .04);
}
.uq-file-icon {
font-size: 18px;
color: var(--text-faint);
}
.uq-info {
min-width: 0;
}
.uq-name {
font-size: 12px;
font-weight: 500;
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.uq-size {
font-size: 10px;
color: var(--text-faint);
margin-top: 1px;
}
.uq-progress-wrap {
grid-column: 1 / -1;
height: 3px;
background: rgba(255, 255, 255, .07);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
/* Vertikaler layout-Trick: progress bar als extra row nach den anderen */
.uq-item {
flex-wrap: wrap;
}
.uq-progress-wrap {
width: 100%;
order: 10;
}
.uq-progress-bar {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 120ms ease;
}
.uq-status-icon { font-size: 16px; }
.uq-status-waiting .uq-status-icon { color: var(--text-faint); }
.uq-status-uploading .uq-status-icon {
color: var(--accent);
animation: spin 1s linear infinite;
}
.uq-status-done .uq-status-icon { color: var(--green); }
.uq-status-error .uq-status-icon { color: var(--red); }
@keyframes spin {
to { transform: rotate(360deg); }
}
.uq-error {
grid-column: 2 / -1;
font-size: 10px;
color: var(--red);
margin-top: 2px;
}
/* /*
Utility Utility
*/ */