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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
──────────────────────────────────────────── */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue