Add MP3 URL import and analytics widgets
This commit is contained in:
parent
e200087a73
commit
5a41b6a622
5 changed files with 380 additions and 19 deletions
100
web/src/App.tsx
100
web/src/App.tsx
|
|
@ -1,11 +1,11 @@
|
|||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume,
|
||||
fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume,
|
||||
adminStatus, adminLogin, adminLogout, adminDelete, adminRename,
|
||||
fetchCategories, partyStart, partyStop, subscribeEvents,
|
||||
getSelectedChannels, setSelectedChannel,
|
||||
} from './api';
|
||||
import type { VoiceChannelInfo, Sound, Category } from './types';
|
||||
import type { VoiceChannelInfo, Sound, Category, AnalyticsResponse } from './types';
|
||||
import { getCookie, setCookie } from './cookies';
|
||||
|
||||
const THEMES = [
|
||||
|
|
@ -30,11 +30,18 @@ export default function App() {
|
|||
const [total, setTotal] = useState(0);
|
||||
const [folders, setFolders] = useState<Array<{ key: string; name: string; count: number }>>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [analytics, setAnalytics] = useState<AnalyticsResponse>({
|
||||
totalSounds: 0,
|
||||
totalPlays: 0,
|
||||
mostPlayed: [],
|
||||
});
|
||||
|
||||
/* ── Navigation ── */
|
||||
const [activeTab, setActiveTab] = useState<Tab>('all');
|
||||
const [activeFolder, setActiveFolder] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [importUrl, setImportUrl] = useState('');
|
||||
const [importBusy, setImportBusy] = useState(false);
|
||||
|
||||
/* ── Channels ── */
|
||||
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
||||
|
|
@ -83,6 +90,14 @@ export default function App() {
|
|||
setTimeout(() => setNotification(null), 3000);
|
||||
}, []);
|
||||
const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []);
|
||||
const isMp3Url = useCallback((value: string) => {
|
||||
try {
|
||||
const parsed = new URL(value.trim());
|
||||
return parsed.pathname.toLowerCase().endsWith('.mp3');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const guildId = selected ? selected.split(':')[0] : '';
|
||||
const channelId = selected ? selected.split(':')[1] : '';
|
||||
|
|
@ -199,6 +214,10 @@ export default function App() {
|
|||
})();
|
||||
}, [activeTab, activeFolder, query, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAnalytics();
|
||||
}, [refreshKey]);
|
||||
|
||||
/* ── Favs persistence ── */
|
||||
useEffect(() => {
|
||||
const c = getCookie('favs');
|
||||
|
|
@ -232,14 +251,41 @@ export default function App() {
|
|||
}, [showAdmin, isAdmin]);
|
||||
|
||||
/* ── Actions ── */
|
||||
async function loadAnalytics() {
|
||||
try {
|
||||
const data = await fetchAnalytics();
|
||||
setAnalytics(data);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
async function handlePlay(s: Sound) {
|
||||
if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error');
|
||||
try {
|
||||
await playSound(s.name, guildId, channelId, volume, s.relativePath);
|
||||
setLastPlayed(s.name);
|
||||
void loadAnalytics();
|
||||
} catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); }
|
||||
}
|
||||
|
||||
async function handleUrlImport() {
|
||||
const trimmed = importUrl.trim();
|
||||
if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error');
|
||||
if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error');
|
||||
if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error');
|
||||
setImportBusy(true);
|
||||
try {
|
||||
await playUrl(trimmed, guildId, channelId, volume);
|
||||
setImportUrl('');
|
||||
notify('MP3 importiert und abgespielt');
|
||||
setRefreshKey(k => k + 1);
|
||||
await loadAnalytics();
|
||||
} catch (e: any) {
|
||||
notify(e?.message || 'URL-Import fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
setImportBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (!selected) return;
|
||||
setLastPlayed('');
|
||||
|
|
@ -410,6 +456,8 @@ export default function App() {
|
|||
[adminFilteredSounds, adminSelection, soundKey]);
|
||||
|
||||
const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length;
|
||||
const analyticsTop = analytics.mostPlayed.slice(0, 3);
|
||||
const totalSoundsDisplay = analytics.totalSounds || total;
|
||||
|
||||
const clockMain = clock.slice(0, 5);
|
||||
const clockSec = clock.slice(5);
|
||||
|
|
@ -538,6 +586,26 @@ export default function App() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="url-import-wrap">
|
||||
<span className="material-icons url-import-icon">link</span>
|
||||
<input
|
||||
className="url-import-input"
|
||||
type="url"
|
||||
placeholder="MP3-URL einfügen..."
|
||||
value={importUrl}
|
||||
onChange={e => setImportUrl(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }}
|
||||
/>
|
||||
<button
|
||||
className="url-import-btn"
|
||||
onClick={() => { void handleUrlImport(); }}
|
||||
disabled={importBusy}
|
||||
title="MP3 importieren"
|
||||
>
|
||||
{importBusy ? 'Lädt...' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-spacer" />
|
||||
|
||||
<div className="volume-control">
|
||||
|
|
@ -612,6 +680,34 @@ export default function App() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-strip">
|
||||
<div className="analytics-card">
|
||||
<span className="material-icons analytics-icon">library_music</span>
|
||||
<div className="analytics-copy">
|
||||
<span className="analytics-label">Sounds gesamt</span>
|
||||
<strong className="analytics-value">{totalSoundsDisplay}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-card analytics-wide">
|
||||
<span className="material-icons analytics-icon">leaderboard</span>
|
||||
<div className="analytics-copy">
|
||||
<span className="analytics-label">Most Played</span>
|
||||
<div className="analytics-top-list">
|
||||
{analyticsTop.length === 0 ? (
|
||||
<span className="analytics-muted">Noch keine Plays</span>
|
||||
) : (
|
||||
analyticsTop.map((item, idx) => (
|
||||
<span className="analytics-chip" key={item.relativePath}>
|
||||
{idx + 1}. {item.name} ({item.count})
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ FOLDER CHIPS ═══ */}
|
||||
{activeTab === 'all' && visibleFolders.length > 0 && (
|
||||
<div className="category-strip">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue