2026-03-06 00:51:07 +01:00
|
|
|
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
|
|
|
|
import './soundboard.css';
|
|
|
|
|
|
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
|
|
|
INLINED TYPES (from original types.ts)
|
|
|
|
|
══════════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
type Sound = {
|
|
|
|
|
fileName: string;
|
|
|
|
|
name: string;
|
|
|
|
|
folder?: string;
|
|
|
|
|
relativePath?: string;
|
|
|
|
|
isRecent?: boolean;
|
|
|
|
|
badges?: string[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type SoundsResponse = {
|
|
|
|
|
items: Sound[];
|
|
|
|
|
total: number;
|
|
|
|
|
folders: Array<{ key: string; name: string; count: number }>;
|
|
|
|
|
categories?: Category[];
|
|
|
|
|
fileCategories?: Record<string, string[]>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type VoiceChannelInfo = {
|
|
|
|
|
guildId: string;
|
|
|
|
|
guildName: string;
|
|
|
|
|
channelId: string;
|
|
|
|
|
channelName: string;
|
|
|
|
|
selected?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type Category = { id: string; name: string; color?: string; sort?: number };
|
|
|
|
|
|
|
|
|
|
type AnalyticsItem = {
|
|
|
|
|
name: string;
|
|
|
|
|
relativePath: string;
|
|
|
|
|
count: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type AnalyticsResponse = {
|
|
|
|
|
totalSounds: number;
|
|
|
|
|
totalPlays: number;
|
|
|
|
|
mostPlayed: AnalyticsItem[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
|
|
|
INLINED COOKIE HELPERS (from original cookies.ts)
|
|
|
|
|
══════════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
function setCookie(name: string, value: string, days = 365): void {
|
|
|
|
|
const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString();
|
|
|
|
|
document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCookie(name: string): string | null {
|
|
|
|
|
const key = `${encodeURIComponent(name)}=`;
|
|
|
|
|
const parts = document.cookie.split(';');
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
const trimmed = part.trim();
|
|
|
|
|
if (trimmed.startsWith(key)) {
|
|
|
|
|
return decodeURIComponent(trimmed.slice(key.length));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
|
|
|
INLINED API FUNCTIONS (from original api.ts)
|
|
|
|
|
All endpoints prefixed with /api/soundboard/ instead of /api/
|
|
|
|
|
══════════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
const API_BASE = '/api/soundboard';
|
|
|
|
|
|
|
|
|
|
async function fetchSounds(q?: string, folderKey?: string, categoryId?: string, fuzzy?: boolean): Promise<SoundsResponse> {
|
|
|
|
|
const url = new URL(`${API_BASE}/sounds`, window.location.origin);
|
|
|
|
|
if (q) url.searchParams.set('q', q);
|
|
|
|
|
if (folderKey !== undefined) url.searchParams.set('folder', folderKey);
|
|
|
|
|
if (categoryId) url.searchParams.set('categoryId', categoryId);
|
|
|
|
|
if (typeof fuzzy === 'boolean') url.searchParams.set('fuzzy', fuzzy ? '1' : '0');
|
|
|
|
|
const res = await fetch(url.toString());
|
|
|
|
|
if (!res.ok) throw new Error('Fehler beim Laden der Sounds');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchAnalytics(): Promise<AnalyticsResponse> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/analytics`);
|
|
|
|
|
if (!res.ok) throw new Error('Fehler beim Laden der Analytics');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchCategories() {
|
|
|
|
|
const res = await fetch(`${API_BASE}/categories`, { credentials: 'include' });
|
|
|
|
|
if (!res.ok) throw new Error('Fehler beim Laden der Kategorien');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchChannels(): Promise<VoiceChannelInfo[]> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/channels`);
|
|
|
|
|
if (!res.ok) throw new Error('Fehler beim Laden der Channels');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getSelectedChannels(): Promise<Record<string, string>> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/selected-channels`);
|
|
|
|
|
if (!res.ok) throw new Error('Fehler beim Laden der Channel-Auswahl');
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
return data?.selected || {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiSetSelectedChannel(guildId: string, channelId: string): Promise<void> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/selected-channel`, {
|
|
|
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ guildId, channelId })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Channel-Auswahl setzen fehlgeschlagen');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiPlaySound(soundName: string, guildId: string, channelId: string, volume: number, relativePath?: string): Promise<void> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/play`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ soundName, guildId, channelId, volume, relativePath })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
throw new Error(data?.error || 'Play fehlgeschlagen');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiPlayUrl(url: string, guildId: string, channelId: string, volume: number): Promise<void> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/play-url`, {
|
|
|
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ url, guildId, channelId, volume })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
throw new Error(data?.error || 'Play-URL fehlgeschlagen');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiPartyStart(guildId: string, channelId: string) {
|
|
|
|
|
const res = await fetch(`${API_BASE}/party/start`, {
|
|
|
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ guildId, channelId })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Partymode Start fehlgeschlagen');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiPartyStop(guildId: string) {
|
|
|
|
|
const res = await fetch(`${API_BASE}/party/stop`, {
|
|
|
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ guildId })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Partymode Stop fehlgeschlagen');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiSetVolumeLive(guildId: string, volume: number): Promise<void> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/volume`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ guildId, volume })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
throw new Error(data?.error || 'Volume aendern fehlgeschlagen');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiGetVolume(guildId: string): Promise<number> {
|
|
|
|
|
const url = new URL(`${API_BASE}/volume`, window.location.origin);
|
|
|
|
|
url.searchParams.set('guildId', guildId);
|
|
|
|
|
const res = await fetch(url.toString());
|
|
|
|
|
if (!res.ok) throw new Error('Fehler beim Laden der Lautstaerke');
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
return typeof data?.volume === 'number' ? data.volume : 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiAdminStatus(): Promise<boolean> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/admin/status`, { credentials: 'include' });
|
|
|
|
|
if (!res.ok) return false;
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
return !!data?.authenticated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiAdminLogin(password: string): Promise<boolean> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/admin/login`, {
|
|
|
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
|
|
|
body: JSON.stringify({ password })
|
|
|
|
|
});
|
|
|
|
|
return res.ok;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiAdminLogout(): Promise<void> {
|
|
|
|
|
await fetch(`${API_BASE}/admin/logout`, { method: 'POST', credentials: 'include' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiAdminDelete(paths: string[]): Promise<void> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/admin/sounds/delete`, {
|
|
|
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
|
|
|
body: JSON.stringify({ paths })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Loeschen fehlgeschlagen');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apiAdminRename(from: string, to: string): Promise<string> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/admin/sounds/rename`, {
|
|
|
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
|
|
|
body: JSON.stringify({ from, to })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error('Umbenennen fehlgeschlagen');
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
return data?.to as string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function apiUploadFile(
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
|
|
|
CONSTANTS
|
|
|
|
|
══════════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
const THEMES = [
|
|
|
|
|
{ id: 'default', color: '#5865f2', label: 'Discord' },
|
|
|
|
|
{ id: 'purple', color: '#9b59b6', label: 'Midnight' },
|
|
|
|
|
{ id: 'forest', color: '#2ecc71', label: 'Forest' },
|
|
|
|
|
{ id: 'sunset', color: '#e67e22', label: 'Sunset' },
|
|
|
|
|
{ id: 'ocean', color: '#3498db', label: 'Ocean' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const CAT_PALETTE = [
|
|
|
|
|
'#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6',
|
|
|
|
|
'#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16',
|
|
|
|
|
'#d946ef', '#0ea5e9', '#f43f5e', '#10b981',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
type Tab = 'all' | 'favorites' | 'recent';
|
|
|
|
|
|
|
|
|
|
type UploadItem = {
|
|
|
|
|
id: string;
|
|
|
|
|
file: File;
|
|
|
|
|
status: 'waiting' | 'uploading' | 'done' | 'error';
|
|
|
|
|
progress: number;
|
|
|
|
|
savedName?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface VoiceStats {
|
|
|
|
|
voicePing: number | null;
|
|
|
|
|
gatewayPing: number;
|
|
|
|
|
status: string;
|
|
|
|
|
channelName: string | null;
|
|
|
|
|
connectedSince: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
|
|
|
PROPS — receives SSE data from the Hub
|
|
|
|
|
══════════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
interface SoundboardTabProps {
|
|
|
|
|
data: any;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
|
|
|
COMPONENT
|
|
|
|
|
══════════════════════════════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
export default function SoundboardTab({ data }: SoundboardTabProps) {
|
|
|
|
|
/* ── Data ── */
|
|
|
|
|
const [sounds, setSounds] = useState<Sound[]>([]);
|
|
|
|
|
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[]>([]);
|
|
|
|
|
const [selected, setSelected] = useState('');
|
|
|
|
|
const selectedRef = useRef('');
|
|
|
|
|
const [channelOpen, setChannelOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
/* ── Playback ── */
|
|
|
|
|
const [volume, setVolume] = useState(1);
|
|
|
|
|
const [lastPlayed, setLastPlayed] = useState('');
|
|
|
|
|
|
|
|
|
|
/* ── Preferences ── */
|
|
|
|
|
const [favs, setFavs] = useState<Record<string, boolean>>({});
|
|
|
|
|
const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'default');
|
|
|
|
|
const [cardSize, setCardSize] = useState(() => parseInt(localStorage.getItem('jb-card-size') || '110'));
|
|
|
|
|
|
|
|
|
|
/* ── Party ── */
|
|
|
|
|
const [chaosMode, setChaosMode] = useState(false);
|
|
|
|
|
const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]);
|
|
|
|
|
const chaosModeRef = useRef(false);
|
2026-03-06 01:00:37 +01:00
|
|
|
const volDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
2026-03-06 00:51:07 +01:00
|
|
|
|
|
|
|
|
/* ── Admin ── */
|
|
|
|
|
const [isAdmin, setIsAdmin] = useState(false);
|
|
|
|
|
const [showAdmin, setShowAdmin] = useState(false);
|
|
|
|
|
const [adminPwd, setAdminPwd] = useState('');
|
|
|
|
|
const [adminSounds, setAdminSounds] = useState<Sound[]>([]);
|
|
|
|
|
const [adminLoading, setAdminLoading] = useState(false);
|
|
|
|
|
const [adminQuery, setAdminQuery] = useState('');
|
|
|
|
|
const [adminSelection, setAdminSelection] = useState<Record<string, boolean>>({});
|
|
|
|
|
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);
|
2026-03-06 01:00:37 +01:00
|
|
|
const uploadDismissRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
2026-03-06 00:51:07 +01:00
|
|
|
|
|
|
|
|
/* ── Voice Stats ── */
|
|
|
|
|
const [voiceStats, setVoiceStats] = useState<VoiceStats | null>(null);
|
|
|
|
|
const [showConnModal, setShowConnModal] = useState(false);
|
|
|
|
|
|
|
|
|
|
/* ── UI ── */
|
|
|
|
|
const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null);
|
|
|
|
|
const [clock, setClock] = useState('');
|
|
|
|
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; sound: Sound } | null>(null);
|
|
|
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
|
|
|
|
|
|
|
|
/* ── Refs ── */
|
|
|
|
|
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
|
|
|
|
|
useEffect(() => { selectedRef.current = selected; }, [selected]);
|
|
|
|
|
|
|
|
|
|
/* ── Drag & Drop: global window listeners ── */
|
|
|
|
|
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 });
|
|
|
|
|
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] : '';
|
|
|
|
|
|
|
|
|
|
const selectedChannel = useMemo(() =>
|
|
|
|
|
channels.find(c => `${c.guildId}:${c.channelId}` === selected),
|
|
|
|
|
[channels, selected]);
|
|
|
|
|
|
|
|
|
|
/* ── Clock ── */
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const update = () => {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const h = String(now.getHours()).padStart(2, '0');
|
|
|
|
|
const m = String(now.getMinutes()).padStart(2, '0');
|
|
|
|
|
const s = String(now.getSeconds()).padStart(2, '0');
|
|
|
|
|
setClock(`${h}:${m}:${s}`);
|
|
|
|
|
};
|
|
|
|
|
update();
|
|
|
|
|
const id = setInterval(update, 1000);
|
|
|
|
|
return () => clearInterval(id);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
/* ── Init ── */
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const [ch, selMap] = await Promise.all([fetchChannels(), getSelectedChannels()]);
|
|
|
|
|
setChannels(ch);
|
|
|
|
|
if (ch.length) {
|
|
|
|
|
const g = ch[0].guildId;
|
|
|
|
|
const serverCid = selMap[g];
|
|
|
|
|
const match = serverCid && ch.find(x => x.guildId === g && x.channelId === serverCid);
|
|
|
|
|
setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`);
|
|
|
|
|
}
|
|
|
|
|
} catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); }
|
|
|
|
|
try { setIsAdmin(await apiAdminStatus()); } catch { }
|
|
|
|
|
try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { }
|
|
|
|
|
})();
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
/* ── Theme (persist only, data-theme is set on .sb-app div) ── */
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
localStorage.setItem('jb-theme', theme);
|
|
|
|
|
}, [theme]);
|
|
|
|
|
|
|
|
|
|
/* ── Card size (scoped to .sb-app container) ── */
|
|
|
|
|
const sbAppRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const el = sbAppRef.current;
|
|
|
|
|
if (!el) return;
|
|
|
|
|
el.style.setProperty('--card-size', cardSize + 'px');
|
|
|
|
|
const ratio = cardSize / 110;
|
|
|
|
|
el.style.setProperty('--card-emoji', Math.round(28 * ratio) + 'px');
|
|
|
|
|
el.style.setProperty('--card-font', Math.max(9, Math.round(11 * ratio)) + 'px');
|
|
|
|
|
localStorage.setItem('jb-card-size', String(cardSize));
|
|
|
|
|
}, [cardSize]);
|
|
|
|
|
|
|
|
|
|
/* ── SSE via props.data instead of own EventSource ── */
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!data) return;
|
|
|
|
|
|
|
|
|
|
// Handle snapshot data (initial load from hub SSE)
|
|
|
|
|
if (data.soundboard) {
|
|
|
|
|
const sb = data.soundboard;
|
|
|
|
|
if (Array.isArray(sb.party)) {
|
|
|
|
|
setPartyActiveGuilds(sb.party);
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const sel = sb.selected || {};
|
|
|
|
|
const g = selectedRef.current?.split(':')[0];
|
|
|
|
|
if (g && sel[g]) setSelected(`${g}:${sel[g]}`);
|
|
|
|
|
} catch { }
|
|
|
|
|
try {
|
|
|
|
|
const vols = sb.volumes || {};
|
|
|
|
|
const g = selectedRef.current?.split(':')[0];
|
|
|
|
|
if (g && typeof vols[g] === 'number') setVolume(vols[g]);
|
|
|
|
|
} catch { }
|
|
|
|
|
try {
|
|
|
|
|
const np = sb.nowplaying || {};
|
|
|
|
|
const g = selectedRef.current?.split(':')[0];
|
|
|
|
|
if (g && typeof np[g] === 'string') setLastPlayed(np[g]);
|
|
|
|
|
} catch { }
|
|
|
|
|
try {
|
|
|
|
|
const vs = sb.voicestats || {};
|
|
|
|
|
const g = selectedRef.current?.split(':')[0];
|
|
|
|
|
if (g && vs[g]) setVoiceStats(vs[g]);
|
|
|
|
|
} catch { }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle individual SSE event types
|
|
|
|
|
if (data.type === 'soundboard_party') {
|
|
|
|
|
setPartyActiveGuilds(prev => {
|
|
|
|
|
const s = new Set(prev);
|
|
|
|
|
if (data.active) s.add(data.guildId); else s.delete(data.guildId);
|
|
|
|
|
return Array.from(s);
|
|
|
|
|
});
|
|
|
|
|
} else if (data.type === 'soundboard_channel') {
|
|
|
|
|
const g = selectedRef.current?.split(':')[0];
|
|
|
|
|
if (data.guildId === g) setSelected(`${data.guildId}:${data.channelId}`);
|
|
|
|
|
} else if (data.type === 'soundboard_volume') {
|
|
|
|
|
const g = selectedRef.current?.split(':')[0];
|
|
|
|
|
if (data.guildId === g && typeof data.volume === 'number') setVolume(data.volume);
|
|
|
|
|
} else if (data.type === 'soundboard_nowplaying') {
|
|
|
|
|
const g = selectedRef.current?.split(':')[0];
|
|
|
|
|
if (data.guildId === g) setLastPlayed(data.name || '');
|
|
|
|
|
} else if (data.type === 'soundboard_voicestats') {
|
|
|
|
|
const g = selectedRef.current?.split(':')[0];
|
|
|
|
|
if (data.guildId === g) {
|
|
|
|
|
setVoiceStats({
|
|
|
|
|
voicePing: data.voicePing,
|
|
|
|
|
gatewayPing: data.gatewayPing,
|
|
|
|
|
status: data.status,
|
|
|
|
|
channelName: data.channelName,
|
|
|
|
|
connectedSince: data.connectedSince,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [data]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setChaosMode(guildId ? partyActiveGuilds.includes(guildId) : false);
|
|
|
|
|
}, [selected, partyActiveGuilds, guildId]);
|
|
|
|
|
|
|
|
|
|
/* ── Data Fetch ── */
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
(async () => {
|
|
|
|
|
try {
|
|
|
|
|
let folderParam = '__all__';
|
|
|
|
|
if (activeTab === 'recent') folderParam = '__recent__';
|
|
|
|
|
else if (activeFolder) folderParam = activeFolder;
|
|
|
|
|
const s = await fetchSounds(query, folderParam, undefined, false);
|
|
|
|
|
setSounds(s.items);
|
|
|
|
|
setTotal(s.total);
|
|
|
|
|
setFolders(s.folders);
|
|
|
|
|
} catch (e: any) { notify(e?.message || 'Sounds-Fehler', 'error'); }
|
|
|
|
|
})();
|
|
|
|
|
}, [activeTab, activeFolder, query, refreshKey, notify]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
void loadAnalytics();
|
|
|
|
|
}, [refreshKey]);
|
|
|
|
|
|
|
|
|
|
/* ── Favs persistence ── */
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const c = getCookie('favs');
|
|
|
|
|
if (c) try { setFavs(JSON.parse(c)); } catch { }
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
try { setCookie('favs', JSON.stringify(favs)); } catch { }
|
|
|
|
|
}, [favs]);
|
|
|
|
|
|
|
|
|
|
/* ── Volume sync ── */
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (selected) {
|
|
|
|
|
(async () => {
|
|
|
|
|
try { const v = await apiGetVolume(guildId); setVolume(v); } catch { }
|
|
|
|
|
})();
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [selected]);
|
|
|
|
|
|
|
|
|
|
/* ── Close dropdowns on outside click ── */
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = () => { setChannelOpen(false); setCtxMenu(null); };
|
|
|
|
|
document.addEventListener('click', handler);
|
|
|
|
|
return () => document.removeEventListener('click', handler);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (showAdmin && isAdmin) {
|
|
|
|
|
void loadAdminSounds();
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [showAdmin, isAdmin]);
|
|
|
|
|
|
|
|
|
|
/* ── Actions ── */
|
|
|
|
|
async function loadAnalytics() {
|
|
|
|
|
try {
|
|
|
|
|
const d = await fetchAnalytics();
|
|
|
|
|
setAnalytics(d);
|
|
|
|
|
} catch { }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handlePlay(s: Sound) {
|
|
|
|
|
if (!selected) return notify('Bitte einen Voice-Channel auswaehlen', 'error');
|
|
|
|
|
try {
|
|
|
|
|
await apiPlaySound(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 auswaehlen', 'error');
|
|
|
|
|
if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error');
|
|
|
|
|
setImportBusy(true);
|
|
|
|
|
try {
|
|
|
|
|
await apiPlayUrl(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 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 apiUploadFile(
|
|
|
|
|
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]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Refresh sound list
|
|
|
|
|
setRefreshKey(k => k + 1);
|
|
|
|
|
void loadAnalytics();
|
|
|
|
|
|
|
|
|
|
// Auto-dismiss after 3.5s
|
|
|
|
|
uploadDismissRef.current = setTimeout(() => {
|
|
|
|
|
setShowUploads(false);
|
|
|
|
|
setUploads([]);
|
|
|
|
|
}, 3500);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleStop() {
|
|
|
|
|
if (!selected) return;
|
|
|
|
|
setLastPlayed('');
|
|
|
|
|
try { await fetch(`${API_BASE}/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleRandom() {
|
|
|
|
|
if (!displaySounds.length || !selected) return;
|
|
|
|
|
const rnd = displaySounds[Math.floor(Math.random() * displaySounds.length)];
|
|
|
|
|
handlePlay(rnd);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function toggleParty() {
|
|
|
|
|
if (chaosMode) {
|
|
|
|
|
await handleStop();
|
|
|
|
|
try { await apiPartyStop(guildId); } catch { }
|
|
|
|
|
} else {
|
|
|
|
|
if (!selected) return notify('Bitte einen Channel auswaehlen', 'error');
|
|
|
|
|
try { await apiPartyStart(guildId, channelId); } catch { }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleChannelSelect(ch: VoiceChannelInfo) {
|
|
|
|
|
const v = `${ch.guildId}:${ch.channelId}`;
|
|
|
|
|
setSelected(v);
|
|
|
|
|
setChannelOpen(false);
|
|
|
|
|
try { await apiSetSelectedChannel(ch.guildId, ch.channelId); } catch { }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleFav(key: string) {
|
|
|
|
|
setFavs(prev => ({ ...prev, [key]: !prev[key] }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadAdminSounds() {
|
|
|
|
|
setAdminLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const d = await fetchSounds('', '__all__', undefined, false);
|
|
|
|
|
setAdminSounds(d.items || []);
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
notify(e?.message || 'Admin-Sounds konnten nicht geladen werden', 'error');
|
|
|
|
|
} finally {
|
|
|
|
|
setAdminLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleAdminSelection(path: string) {
|
|
|
|
|
setAdminSelection(prev => ({ ...prev, [path]: !prev[path] }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startRename(sound: Sound) {
|
|
|
|
|
setRenameTarget(soundKey(sound));
|
|
|
|
|
setRenameValue(sound.name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelRename() {
|
|
|
|
|
setRenameTarget('');
|
|
|
|
|
setRenameValue('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function submitRename() {
|
|
|
|
|
if (!renameTarget) return;
|
|
|
|
|
const baseName = renameValue.trim().replace(/\.(mp3|wav)$/i, '');
|
|
|
|
|
if (!baseName) {
|
|
|
|
|
notify('Bitte einen gueltigen Namen eingeben', 'error');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await apiAdminRename(renameTarget, baseName);
|
|
|
|
|
notify('Sound umbenannt');
|
|
|
|
|
cancelRename();
|
|
|
|
|
setRefreshKey(k => k + 1);
|
|
|
|
|
if (showAdmin) await loadAdminSounds();
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
notify(e?.message || 'Umbenennen fehlgeschlagen', 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteAdminPaths(paths: string[]) {
|
|
|
|
|
if (paths.length === 0) return;
|
|
|
|
|
try {
|
|
|
|
|
await apiAdminDelete(paths);
|
|
|
|
|
notify(paths.length === 1 ? 'Sound geloescht' : `${paths.length} Sounds geloescht`);
|
|
|
|
|
setAdminSelection({});
|
|
|
|
|
cancelRename();
|
|
|
|
|
setRefreshKey(k => k + 1);
|
|
|
|
|
if (showAdmin) await loadAdminSounds();
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
notify(e?.message || 'Loeschen fehlgeschlagen', 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleAdminLogin() {
|
|
|
|
|
try {
|
|
|
|
|
const ok = await apiAdminLogin(adminPwd);
|
|
|
|
|
if (ok) {
|
|
|
|
|
setIsAdmin(true);
|
|
|
|
|
setAdminPwd('');
|
|
|
|
|
notify('Admin eingeloggt');
|
|
|
|
|
}
|
|
|
|
|
else notify('Falsches Passwort', 'error');
|
|
|
|
|
} catch { notify('Login fehlgeschlagen', 'error'); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleAdminLogout() {
|
|
|
|
|
try {
|
|
|
|
|
await apiAdminLogout();
|
|
|
|
|
setIsAdmin(false);
|
|
|
|
|
setAdminSelection({});
|
|
|
|
|
cancelRename();
|
|
|
|
|
notify('Ausgeloggt');
|
|
|
|
|
} catch { }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Computed ── */
|
|
|
|
|
const displaySounds = useMemo(() => {
|
|
|
|
|
if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]);
|
|
|
|
|
return sounds;
|
|
|
|
|
}, [sounds, activeTab, favs]);
|
|
|
|
|
|
|
|
|
|
const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]);
|
|
|
|
|
|
|
|
|
|
const visibleFolders = useMemo(() =>
|
|
|
|
|
folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)),
|
|
|
|
|
[folders]);
|
|
|
|
|
|
|
|
|
|
const folderColorMap = useMemo(() => {
|
|
|
|
|
const m: Record<string, string> = {};
|
|
|
|
|
visibleFolders.forEach((f, i) => { m[f.key] = CAT_PALETTE[i % CAT_PALETTE.length]; });
|
|
|
|
|
return m;
|
|
|
|
|
}, [visibleFolders]);
|
|
|
|
|
|
|
|
|
|
const firstOfInitial = useMemo(() => {
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
const result = new Set<number>();
|
|
|
|
|
displaySounds.forEach((s, idx) => {
|
|
|
|
|
const ch = s.name.charAt(0).toUpperCase();
|
|
|
|
|
if (!seen.has(ch)) { seen.add(ch); result.add(idx); }
|
|
|
|
|
});
|
|
|
|
|
return result;
|
|
|
|
|
}, [displaySounds]);
|
|
|
|
|
|
|
|
|
|
const channelsByGuild = useMemo(() => {
|
|
|
|
|
const groups: Record<string, VoiceChannelInfo[]> = {};
|
|
|
|
|
channels.forEach(c => {
|
|
|
|
|
if (!groups[c.guildName]) groups[c.guildName] = [];
|
|
|
|
|
groups[c.guildName].push(c);
|
|
|
|
|
});
|
|
|
|
|
return groups;
|
|
|
|
|
}, [channels]);
|
|
|
|
|
|
|
|
|
|
const adminFilteredSounds = useMemo(() => {
|
|
|
|
|
const q = adminQuery.trim().toLowerCase();
|
|
|
|
|
if (!q) return adminSounds;
|
|
|
|
|
return adminSounds.filter(s => {
|
|
|
|
|
const key = soundKey(s).toLowerCase();
|
|
|
|
|
return s.name.toLowerCase().includes(q)
|
|
|
|
|
|| (s.folder || '').toLowerCase().includes(q)
|
|
|
|
|
|| key.includes(q);
|
|
|
|
|
});
|
|
|
|
|
}, [adminQuery, adminSounds, soundKey]);
|
|
|
|
|
|
|
|
|
|
const selectedAdminPaths = useMemo(() =>
|
|
|
|
|
Object.keys(adminSelection).filter(k => adminSelection[k]),
|
|
|
|
|
[adminSelection]);
|
|
|
|
|
|
|
|
|
|
const selectedVisibleCount = useMemo(() =>
|
|
|
|
|
adminFilteredSounds.filter(s => !!adminSelection[soundKey(s)]).length,
|
|
|
|
|
[adminFilteredSounds, adminSelection, soundKey]);
|
|
|
|
|
|
|
|
|
|
const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length;
|
|
|
|
|
const analyticsTop = analytics.mostPlayed.slice(0, 10);
|
|
|
|
|
const totalSoundsDisplay = analytics.totalSounds || total;
|
|
|
|
|
|
|
|
|
|
const clockMain = clock.slice(0, 5);
|
|
|
|
|
const clockSec = clock.slice(5);
|
|
|
|
|
|
|
|
|
|
/* ════════════════════════════════════════════
|
|
|
|
|
RENDER
|
|
|
|
|
════════════════════════════════════════════ */
|
|
|
|
|
return (
|
|
|
|
|
<div className="sb-app" data-theme={theme} ref={sbAppRef}>
|
|
|
|
|
{chaosMode && <div className="party-overlay active" />}
|
|
|
|
|
|
|
|
|
|
{/* ═══ TOPBAR ═══ */}
|
|
|
|
|
<header className="topbar">
|
|
|
|
|
<div className="topbar-left">
|
|
|
|
|
<div className="sb-app-logo">
|
|
|
|
|
<span className="material-icons" style={{ fontSize: 16, color: 'white' }}>music_note</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="sb-app-title">Soundboard</span>
|
|
|
|
|
|
|
|
|
|
{/* Channel Dropdown */}
|
|
|
|
|
<div className="channel-dropdown" onClick={e => e.stopPropagation()}>
|
|
|
|
|
<button
|
|
|
|
|
className={`channel-btn ${channelOpen ? 'open' : ''}`}
|
|
|
|
|
onClick={() => setChannelOpen(!channelOpen)}
|
|
|
|
|
>
|
|
|
|
|
<span className="material-icons cb-icon">headset</span>
|
|
|
|
|
{selected && <span className="channel-status" />}
|
|
|
|
|
<span className="channel-label">{selectedChannel?.channelName || 'Channel...'}</span>
|
|
|
|
|
<span className={`material-icons chevron`}>expand_more</span>
|
|
|
|
|
</button>
|
|
|
|
|
{channelOpen && (
|
|
|
|
|
<div className="channel-menu visible">
|
|
|
|
|
{Object.entries(channelsByGuild).map(([guild, chs]) => (
|
|
|
|
|
<React.Fragment key={guild}>
|
|
|
|
|
<div className="channel-menu-header">{guild}</div>
|
|
|
|
|
{chs.map(ch => (
|
|
|
|
|
<div
|
|
|
|
|
key={`${ch.guildId}:${ch.channelId}`}
|
|
|
|
|
className={`channel-option ${`${ch.guildId}:${ch.channelId}` === selected ? 'active' : ''}`}
|
|
|
|
|
onClick={() => handleChannelSelect(ch)}
|
|
|
|
|
>
|
|
|
|
|
<span className="material-icons co-icon">volume_up</span>
|
|
|
|
|
{ch.channelName}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
))}
|
|
|
|
|
{channels.length === 0 && (
|
|
|
|
|
<div className="channel-option" style={{ color: 'var(--text-faint)', cursor: 'default' }}>
|
|
|
|
|
Keine Channels verfuegbar
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="clock-wrap">
|
|
|
|
|
<div className="clock">{clockMain}<span className="clock-seconds">{clockSec}</span></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="topbar-right">
|
|
|
|
|
{lastPlayed && (
|
|
|
|
|
<div className="now-playing">
|
|
|
|
|
<div className="np-waves active">
|
|
|
|
|
<div className="np-wave-bar" /><div className="np-wave-bar" />
|
|
|
|
|
<div className="np-wave-bar" /><div className="np-wave-bar" />
|
|
|
|
|
</div>
|
|
|
|
|
<span className="np-label">Last Played:</span> <span className="np-name">{lastPlayed}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{selected && (
|
|
|
|
|
<div className="connection" onClick={() => setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails">
|
|
|
|
|
<span className="conn-dot" />
|
|
|
|
|
Verbunden
|
|
|
|
|
{voiceStats?.voicePing != null && (
|
|
|
|
|
<span className="conn-ping">{voiceStats.voicePing}ms</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
className={`admin-btn-icon ${isAdmin ? 'active' : ''}`}
|
|
|
|
|
onClick={() => setShowAdmin(true)}
|
|
|
|
|
title="Admin"
|
|
|
|
|
>
|
|
|
|
|
<span className="material-icons">settings</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{/* ═══ TOOLBAR ═══ */}
|
|
|
|
|
<div className="toolbar">
|
|
|
|
|
<div className="cat-tabs">
|
|
|
|
|
<button
|
|
|
|
|
className={`cat-tab ${activeTab === 'all' ? 'active' : ''}`}
|
|
|
|
|
onClick={() => { setActiveTab('all'); setActiveFolder(''); }}
|
|
|
|
|
>
|
|
|
|
|
Alle
|
|
|
|
|
<span className="tab-count">{total}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className={`cat-tab ${activeTab === 'recent' ? 'active' : ''}`}
|
|
|
|
|
onClick={() => { setActiveTab('recent'); setActiveFolder(''); }}
|
|
|
|
|
>
|
|
|
|
|
Neu hinzugefuegt
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className={`cat-tab ${activeTab === 'favorites' ? 'active' : ''}`}
|
|
|
|
|
onClick={() => { setActiveTab('favorites'); setActiveFolder(''); }}
|
|
|
|
|
>
|
|
|
|
|
Favoriten
|
|
|
|
|
{favCount > 0 && <span className="tab-count">{favCount}</span>}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="search-wrap">
|
|
|
|
|
<span className="material-icons search-icon">search</span>
|
|
|
|
|
<input
|
|
|
|
|
className="search-input"
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="Suchen..."
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={e => setQuery(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
{query && (
|
|
|
|
|
<button className="search-clear" onClick={() => setQuery('')}>
|
|
|
|
|
<span className="material-icons" style={{ fontSize: 14 }}>close</span>
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</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 einfuegen..."
|
|
|
|
|
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 ? 'Laedt...' : 'Download'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="toolbar-spacer" />
|
|
|
|
|
|
|
|
|
|
<div className="volume-control">
|
|
|
|
|
<span
|
|
|
|
|
className="material-icons vol-icon"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const newVol = volume > 0 ? 0 : 0.5;
|
|
|
|
|
setVolume(newVol);
|
|
|
|
|
if (guildId) apiSetVolumeLive(guildId, newVol).catch(() => {});
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'}
|
|
|
|
|
</span>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
className="vol-slider"
|
|
|
|
|
min={0}
|
|
|
|
|
max={1}
|
|
|
|
|
step={0.01}
|
|
|
|
|
value={volume}
|
|
|
|
|
onChange={e => {
|
|
|
|
|
const v = parseFloat(e.target.value);
|
|
|
|
|
setVolume(v);
|
|
|
|
|
if (guildId) {
|
|
|
|
|
if (volDebounceRef.current) clearTimeout(volDebounceRef.current);
|
|
|
|
|
volDebounceRef.current = setTimeout(() => {
|
|
|
|
|
apiSetVolumeLive(guildId, v).catch(() => {});
|
|
|
|
|
}, 120);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties}
|
|
|
|
|
/>
|
|
|
|
|
<span className="vol-pct">{Math.round(volume * 100)}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button className="tb-btn random" onClick={handleRandom} title="Zufaelliger Sound">
|
|
|
|
|
<span className="material-icons tb-icon">shuffle</span>
|
|
|
|
|
Random
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
className={`tb-btn party ${chaosMode ? 'active' : ''}`}
|
|
|
|
|
onClick={toggleParty}
|
|
|
|
|
title="Party Mode"
|
|
|
|
|
>
|
|
|
|
|
<span className="material-icons tb-icon">{chaosMode ? 'celebration' : 'auto_awesome'}</span>
|
|
|
|
|
{chaosMode ? 'Party!' : 'Party'}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button className="tb-btn stop" onClick={handleStop} title="Alle stoppen">
|
|
|
|
|
<span className="material-icons tb-icon">stop</span>
|
|
|
|
|
Stop
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<div className="size-control" title="Button-Groesse">
|
|
|
|
|
<span className="material-icons sc-icon">grid_view</span>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
className="size-slider"
|
|
|
|
|
min={80}
|
|
|
|
|
max={160}
|
|
|
|
|
value={cardSize}
|
|
|
|
|
onChange={e => setCardSize(parseInt(e.target.value))}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="theme-selector">
|
|
|
|
|
{THEMES.map(t => (
|
|
|
|
|
<div
|
|
|
|
|
key={t.id}
|
|
|
|
|
className={`theme-dot ${theme === t.id ? 'active' : ''}`}
|
|
|
|
|
style={{ background: t.color }}
|
|
|
|
|
title={t.label}
|
|
|
|
|
onClick={() => setTheme(t.id)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</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">
|
|
|
|
|
{visibleFolders.map(f => {
|
|
|
|
|
const color = folderColorMap[f.key] || '#888';
|
|
|
|
|
const isActive = activeFolder === f.key;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={f.key}
|
|
|
|
|
className={`cat-chip ${isActive ? 'active' : ''}`}
|
|
|
|
|
onClick={() => setActiveFolder(isActive ? '' : f.key)}
|
|
|
|
|
style={isActive ? { borderColor: color, color } : undefined}
|
|
|
|
|
>
|
|
|
|
|
<span className="cat-dot" style={{ background: color }} />
|
|
|
|
|
{f.name.replace(/\s*\(\d+\)\s*$/, '')}
|
|
|
|
|
<span className="cat-count">{f.count}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ═══ MAIN ═══ */}
|
|
|
|
|
<main className="main">
|
|
|
|
|
{displaySounds.length === 0 ? (
|
|
|
|
|
<div className="empty-state visible">
|
|
|
|
|
<div className="empty-emoji">{activeTab === 'favorites' ? '\u2B50' : '\uD83D\uDD07'}</div>
|
|
|
|
|
<div className="empty-title">
|
|
|
|
|
{activeTab === 'favorites'
|
|
|
|
|
? 'Noch keine Favoriten'
|
|
|
|
|
: query
|
|
|
|
|
? `Kein Sound fuer "${query}" gefunden`
|
|
|
|
|
: 'Keine Sounds vorhanden'}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="empty-desc">
|
|
|
|
|
{activeTab === 'favorites'
|
|
|
|
|
? 'Klick den Stern auf einem Sound!'
|
|
|
|
|
: 'Hier gibt\'s noch nichts zu hoeren.'}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="sound-grid">
|
|
|
|
|
{displaySounds.map((s, idx) => {
|
|
|
|
|
const key = s.relativePath ?? s.fileName;
|
|
|
|
|
const isFav = !!favs[key];
|
|
|
|
|
const isPlaying = lastPlayed === s.name;
|
|
|
|
|
const isNew = s.isRecent || s.badges?.includes('new');
|
|
|
|
|
const initial = s.name.charAt(0).toUpperCase();
|
|
|
|
|
const showInitial = firstOfInitial.has(idx);
|
|
|
|
|
const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={key}
|
|
|
|
|
className={`sound-card ${isPlaying ? 'playing' : ''} ${showInitial ? 'has-initial' : ''}`}
|
|
|
|
|
style={{ animationDelay: `${Math.min(idx * 20, 400)}ms` }}
|
|
|
|
|
onClick={e => {
|
|
|
|
|
const card = e.currentTarget;
|
|
|
|
|
const rect = card.getBoundingClientRect();
|
|
|
|
|
const ripple = document.createElement('div');
|
|
|
|
|
ripple.className = 'ripple';
|
|
|
|
|
const sz = Math.max(rect.width, rect.height);
|
|
|
|
|
ripple.style.width = ripple.style.height = sz + 'px';
|
|
|
|
|
ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px';
|
|
|
|
|
ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px';
|
|
|
|
|
card.appendChild(ripple);
|
|
|
|
|
setTimeout(() => ripple.remove(), 500);
|
|
|
|
|
handlePlay(s);
|
|
|
|
|
}}
|
|
|
|
|
onContextMenu={e => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setCtxMenu({
|
|
|
|
|
x: Math.min(e.clientX, window.innerWidth - 170),
|
|
|
|
|
y: Math.min(e.clientY, window.innerHeight - 140),
|
|
|
|
|
sound: s,
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`}
|
|
|
|
|
>
|
|
|
|
|
{isNew && <span className="new-badge">NEU</span>}
|
|
|
|
|
<span
|
|
|
|
|
className={`fav-star ${isFav ? 'active' : ''}`}
|
|
|
|
|
onClick={e => { e.stopPropagation(); toggleFav(key); }}
|
|
|
|
|
>
|
|
|
|
|
<span className="material-icons fav-icon">{isFav ? 'star' : 'star_border'}</span>
|
|
|
|
|
</span>
|
|
|
|
|
{showInitial && <span className="sound-emoji" style={{ color: folderColor }}>{initial}</span>}
|
|
|
|
|
<span className="sound-name">{s.name}</span>
|
|
|
|
|
{s.folder && <span className="sound-duration">{s.folder}</span>}
|
|
|
|
|
<div className="playing-indicator">
|
|
|
|
|
<div className="wave-bar" /><div className="wave-bar" />
|
|
|
|
|
<div className="wave-bar" /><div className="wave-bar" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
{/* ═══ CONTEXT MENU ═══ */}
|
|
|
|
|
{ctxMenu && (
|
|
|
|
|
<div
|
|
|
|
|
className="ctx-menu visible"
|
|
|
|
|
style={{ left: ctxMenu.x, top: ctxMenu.y }}
|
|
|
|
|
onClick={e => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<div className="ctx-item" onClick={() => { handlePlay(ctxMenu.sound); setCtxMenu(null); }}>
|
|
|
|
|
<span className="material-icons ctx-icon">play_arrow</span>
|
|
|
|
|
Abspielen
|
|
|
|
|
</div>
|
|
|
|
|
<div className="ctx-item" onClick={() => {
|
|
|
|
|
toggleFav(ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName);
|
|
|
|
|
setCtxMenu(null);
|
|
|
|
|
}}>
|
|
|
|
|
<span className="material-icons ctx-icon">
|
|
|
|
|
{favs[ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName] ? 'star' : 'star_border'}
|
|
|
|
|
</span>
|
|
|
|
|
Favorit
|
|
|
|
|
</div>
|
|
|
|
|
{isAdmin && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="ctx-sep" />
|
|
|
|
|
<div className="ctx-item danger" onClick={async () => {
|
|
|
|
|
const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName;
|
|
|
|
|
await deleteAdminPaths([path]);
|
|
|
|
|
setCtxMenu(null);
|
|
|
|
|
}}>
|
|
|
|
|
<span className="material-icons ctx-icon">delete</span>
|
|
|
|
|
Loeschen
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ═══ CONNECTION MODAL ═══ */}
|
|
|
|
|
{showConnModal && voiceStats && (() => {
|
|
|
|
|
const uptimeSec = voiceStats.connectedSince
|
|
|
|
|
? Math.floor((Date.now() - new Date(voiceStats.connectedSince).getTime()) / 1000)
|
|
|
|
|
: 0;
|
|
|
|
|
const h = Math.floor(uptimeSec / 3600);
|
|
|
|
|
const m = Math.floor((uptimeSec % 3600) / 60);
|
|
|
|
|
const s = uptimeSec % 60;
|
|
|
|
|
const uptimeStr = h > 0
|
|
|
|
|
? `${h}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s`
|
|
|
|
|
: m > 0
|
|
|
|
|
? `${m}m ${String(s).padStart(2,'0')}s`
|
|
|
|
|
: `${s}s`;
|
|
|
|
|
const pingColor = (ms: number | null) =>
|
|
|
|
|
ms == null ? 'var(--muted)' : ms < 80 ? 'var(--green)' : ms < 150 ? '#f0a830' : '#e04040';
|
|
|
|
|
return (
|
|
|
|
|
<div className="conn-modal-overlay" onClick={() => setShowConnModal(false)}>
|
|
|
|
|
<div className="conn-modal" onClick={e => e.stopPropagation()}>
|
|
|
|
|
<div className="conn-modal-header">
|
|
|
|
|
<span className="material-icons" style={{fontSize:20,color:'var(--green)'}}>cell_tower</span>
|
|
|
|
|
<span>Verbindungsdetails</span>
|
|
|
|
|
<button className="conn-modal-close" onClick={() => setShowConnModal(false)}>
|
|
|
|
|
<span className="material-icons">close</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="conn-modal-body">
|
|
|
|
|
<div className="conn-stat">
|
|
|
|
|
<span className="conn-stat-label">Voice Ping</span>
|
|
|
|
|
<span className="conn-stat-value">
|
|
|
|
|
<span className="conn-ping-dot" style={{background: pingColor(voiceStats.voicePing)}} />
|
|
|
|
|
{voiceStats.voicePing != null ? `${voiceStats.voicePing} ms` : '---'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="conn-stat">
|
|
|
|
|
<span className="conn-stat-label">Gateway Ping</span>
|
|
|
|
|
<span className="conn-stat-value">
|
|
|
|
|
<span className="conn-ping-dot" style={{background: pingColor(voiceStats.gatewayPing)}} />
|
|
|
|
|
{voiceStats.gatewayPing >= 0 ? `${voiceStats.gatewayPing} ms` : '---'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="conn-stat">
|
|
|
|
|
<span className="conn-stat-label">Status</span>
|
|
|
|
|
<span className="conn-stat-value" style={{color: voiceStats.status === 'ready' ? 'var(--green)' : '#f0a830'}}>
|
|
|
|
|
{voiceStats.status === 'ready' ? 'Verbunden' : voiceStats.status}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="conn-stat">
|
|
|
|
|
<span className="conn-stat-label">Kanal</span>
|
|
|
|
|
<span className="conn-stat-value">{voiceStats.channelName || '---'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="conn-stat">
|
|
|
|
|
<span className="conn-stat-label">Verbunden seit</span>
|
|
|
|
|
<span className="conn-stat-value">{uptimeStr}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
|
|
|
|
|
{/* ═══ TOAST ═══ */}
|
|
|
|
|
{notification && (
|
|
|
|
|
<div className={`toast ${notification.type}`}>
|
|
|
|
|
<span className="material-icons toast-icon">
|
|
|
|
|
{notification.type === 'error' ? 'error_outline' : 'check_circle'}
|
|
|
|
|
</span>
|
|
|
|
|
{notification.msg}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ═══ ADMIN PANEL ═══ */}
|
|
|
|
|
{showAdmin && (
|
|
|
|
|
<div className="admin-overlay" onClick={e => { if (e.target === e.currentTarget) setShowAdmin(false); }}>
|
|
|
|
|
<div className="admin-panel">
|
|
|
|
|
<h3>
|
|
|
|
|
Admin
|
|
|
|
|
<button className="admin-close" onClick={() => setShowAdmin(false)}>
|
|
|
|
|
<span className="material-icons" style={{ fontSize: 18 }}>close</span>
|
|
|
|
|
</button>
|
|
|
|
|
</h3>
|
|
|
|
|
{!isAdmin ? (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="admin-field">
|
|
|
|
|
<label>Passwort</label>
|
|
|
|
|
<input
|
|
|
|
|
type="password"
|
|
|
|
|
value={adminPwd}
|
|
|
|
|
onChange={e => setAdminPwd(e.target.value)}
|
|
|
|
|
onKeyDown={e => e.key === 'Enter' && handleAdminLogin()}
|
|
|
|
|
placeholder="Admin-Passwort..."
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<button className="admin-btn-action primary" onClick={handleAdminLogin}>Login</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="admin-shell">
|
|
|
|
|
<div className="admin-header-row">
|
|
|
|
|
<p className="admin-status">Eingeloggt als Admin</p>
|
|
|
|
|
<div className="admin-actions-inline">
|
|
|
|
|
<button
|
|
|
|
|
className="admin-btn-action outline"
|
|
|
|
|
onClick={() => { void loadAdminSounds(); }}
|
|
|
|
|
disabled={adminLoading}
|
|
|
|
|
>
|
|
|
|
|
Aktualisieren
|
|
|
|
|
</button>
|
|
|
|
|
<button className="admin-btn-action outline" onClick={handleAdminLogout}>Logout</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="admin-field admin-search-field">
|
|
|
|
|
<label>Sounds verwalten</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={adminQuery}
|
|
|
|
|
onChange={e => setAdminQuery(e.target.value)}
|
|
|
|
|
placeholder="Nach Name, Ordner oder Pfad filtern..."
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="admin-bulk-row">
|
|
|
|
|
<label className="admin-select-all">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={allVisibleSelected}
|
|
|
|
|
onChange={e => {
|
|
|
|
|
const checked = e.target.checked;
|
|
|
|
|
const next = { ...adminSelection };
|
|
|
|
|
adminFilteredSounds.forEach(s => { next[soundKey(s)] = checked; });
|
|
|
|
|
setAdminSelection(next);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<span>Alle sichtbaren auswaehlen ({selectedVisibleCount}/{adminFilteredSounds.length})</span>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
className="admin-btn-action danger"
|
|
|
|
|
disabled={selectedAdminPaths.length === 0}
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
if (!window.confirm(`Wirklich ${selectedAdminPaths.length} Sound(s) loeschen?`)) return;
|
|
|
|
|
await deleteAdminPaths(selectedAdminPaths);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Ausgewaehlte loeschen
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="admin-list-wrap">
|
|
|
|
|
{adminLoading ? (
|
|
|
|
|
<div className="admin-empty">Lade Sounds...</div>
|
|
|
|
|
) : adminFilteredSounds.length === 0 ? (
|
|
|
|
|
<div className="admin-empty">Keine Sounds gefunden.</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="admin-list">
|
|
|
|
|
{adminFilteredSounds.map(sound => {
|
|
|
|
|
const key = soundKey(sound);
|
|
|
|
|
const editing = renameTarget === key;
|
|
|
|
|
return (
|
|
|
|
|
<div className="admin-item" key={key}>
|
|
|
|
|
<label className="admin-item-check">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={!!adminSelection[key]}
|
|
|
|
|
onChange={() => toggleAdminSelection(key)}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<div className="admin-item-main">
|
|
|
|
|
<div className="admin-item-name">{sound.name}</div>
|
|
|
|
|
<div className="admin-item-meta">
|
|
|
|
|
{sound.folder ? `Ordner: ${sound.folder}` : 'Root'}
|
|
|
|
|
{' \u00B7 '}
|
|
|
|
|
{key}
|
|
|
|
|
</div>
|
|
|
|
|
{editing && (
|
|
|
|
|
<div className="admin-rename-row">
|
|
|
|
|
<input
|
|
|
|
|
value={renameValue}
|
|
|
|
|
onChange={e => setRenameValue(e.target.value)}
|
|
|
|
|
onKeyDown={e => {
|
|
|
|
|
if (e.key === 'Enter') void submitRename();
|
|
|
|
|
if (e.key === 'Escape') cancelRename();
|
|
|
|
|
}}
|
|
|
|
|
placeholder="Neuer Name..."
|
|
|
|
|
/>
|
|
|
|
|
<button className="admin-btn-action primary" onClick={() => { void submitRename(); }}>
|
|
|
|
|
Speichern
|
|
|
|
|
</button>
|
|
|
|
|
<button className="admin-btn-action outline" onClick={cancelRename}>
|
|
|
|
|
Abbrechen
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!editing && (
|
|
|
|
|
<div className="admin-item-actions">
|
|
|
|
|
<button className="admin-btn-action outline" onClick={() => startRename(sound)}>
|
|
|
|
|
Umbenennen
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="admin-btn-action danger ghost"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
if (!window.confirm(`Sound "${sound.name}" loeschen?`)) return;
|
|
|
|
|
await deleteAdminPaths([key]);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Loeschen
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</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 moeglich</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\u2026 (${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>
|
|
|
|
|
);
|
|
|
|
|
}
|