From 52c86240af0fa22aa7305c8b2dc6e53eaef47230 Mon Sep 17 00:00:00 2001 From: Bot Date: Sun, 1 Mar 2026 22:15:07 +0100 Subject: [PATCH] Feat: Drag & Drop MP3/WAV Upload mit Progress-Tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/index.ts | 43 ++++++++- web/src/App.tsx | 151 +++++++++++++++++++++++++++++++- web/src/api.ts | 29 ++++++- web/src/styles.css | 206 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 426 insertions(+), 3 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 777444e..11b1133 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import express, { Request, Response } from 'express'; -// import multer from 'multer'; +import multer from 'multer'; import cors from 'cors'; import crypto from 'node:crypto'; 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 --- app.get('/api/categories', (_req: Request, res: Response) => { res.json({ categories: persistedState.categories ?? [] }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 5bd90c5..dd144bb 100644 --- a/web/src/App.tsx +++ b/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([]); @@ -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([]); + const [showUploads, setShowUploads] = useState(false); + const dragCounterRef = useRef(0); + const uploadDismissRef = useRef>(); + /* ── 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() { )} + + {/* ── Drag & Drop Overlay ── */} + {isDragging && ( +
+
+ cloud_upload +
MP3 & WAV hier ablegen
+
Mehrere Dateien gleichzeitig möglich
+
+
+ )} + + {/* ── Upload-Queue ── */} + {showUploads && uploads.length > 0 && ( +
+
+ upload + + {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})`} + + +
+
+ {uploads.map(u => ( +
+ audio_file +
+
+ {u.savedName ?? u.file.name} +
+
{(u.file.size / 1024).toFixed(0)} KB
+
+ {(u.status === 'waiting' || u.status === 'uploading') && ( +
+
+
+ )} + + {u.status === 'done' ? 'check_circle' : + u.status === 'error' ? 'error' : + u.status === 'uploading' ? 'sync' : 'schedule'} + + {u.status === 'error' &&
{u.error}
} +
+ ))} +
+
+ )}
); } diff --git a/web/src/api.ts b/web/src/api.ts index 4b6b0fd..4cf8736 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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 { + 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); + }); +} diff --git a/web/src/styles.css b/web/src/styles.css index 66abf75..4fa09d6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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 ──────────────────────────────────────────── */