2025-08-08 13:14:27 +02:00
|
|
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
2025-08-08 18:22:37 +02:00
|
|
|
|
import ReactDOM from 'react-dom';
|
2025-08-09 21:12:02 +02:00
|
|
|
|
import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl, fetchCategories, createCategory, assignCategories, assignBadges, clearBadges, updateCategory, deleteCategory } from './api';
|
2025-08-09 17:21:01 +02:00
|
|
|
|
import type { VoiceChannelInfo, Sound, Category } from './types';
|
2025-08-08 03:21:01 +02:00
|
|
|
|
import { getCookie, setCookie } from './cookies';
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
|
|
|
|
|
export default function App() {
|
|
|
|
|
|
const [sounds, setSounds] = useState<Sound[]>([]);
|
2025-08-08 01:40:49 +02:00
|
|
|
|
const [total, setTotal] = useState<number>(0);
|
2025-08-08 01:56:30 +02:00
|
|
|
|
const [folders, setFolders] = useState<Array<{ key: string; name: string; count: number }>>([]);
|
|
|
|
|
|
const [activeFolder, setActiveFolder] = useState<string>('__all__');
|
2025-08-09 17:21:01 +02:00
|
|
|
|
const [categories, setCategories] = useState<Category[]>([]);
|
|
|
|
|
|
const [activeCategoryId, setActiveCategoryId] = useState<string>('');
|
2025-08-07 23:24:56 +02:00
|
|
|
|
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
|
|
|
|
|
const [query, setQuery] = useState('');
|
|
|
|
|
|
const [selected, setSelected] = useState<string>('');
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2025-08-08 18:40:40 +02:00
|
|
|
|
const [info, setInfo] = useState<string | null>(null);
|
2025-08-08 18:51:57 +02:00
|
|
|
|
const [showTop, setShowTop] = useState<boolean>(false);
|
2025-08-08 01:23:52 +02:00
|
|
|
|
const [volume, setVolume] = useState<number>(1);
|
2025-08-08 03:21:01 +02:00
|
|
|
|
const [favs, setFavs] = useState<Record<string, boolean>>({});
|
2025-08-08 13:17:29 +02:00
|
|
|
|
const [theme, setTheme] = useState<string>(() => localStorage.getItem('theme') || 'dark');
|
2025-08-08 14:23:18 +02:00
|
|
|
|
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
|
|
|
|
|
const [adminPwd, setAdminPwd] = useState<string>('');
|
|
|
|
|
|
const [selectedSet, setSelectedSet] = useState<Record<string, boolean>>({});
|
2025-08-09 17:21:01 +02:00
|
|
|
|
const [assignCategoryId, setAssignCategoryId] = useState<string>('');
|
|
|
|
|
|
const [newCategoryName, setNewCategoryName] = useState<string>('');
|
2025-08-09 17:30:21 +02:00
|
|
|
|
const [editingCategoryId, setEditingCategoryId] = useState<string>('');
|
|
|
|
|
|
const [editingCategoryName, setEditingCategoryName] = useState<string>('');
|
|
|
|
|
|
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
|
|
|
|
|
const emojiPickerRef = useRef<HTMLDivElement|null>(null);
|
2025-08-09 21:29:11 +02:00
|
|
|
|
const emojiTriggerRef = useRef<HTMLButtonElement|null>(null);
|
|
|
|
|
|
const [emojiPos, setEmojiPos] = useState<{left:number; top:number}>({ left: 0, top: 0 });
|
2025-08-09 17:30:21 +02:00
|
|
|
|
const EMOJIS = useMemo(()=>{
|
|
|
|
|
|
// einfache, breite Auswahl gängiger Emojis; kann später erweitert/extern geladen werden
|
|
|
|
|
|
const groups = [
|
|
|
|
|
|
'😀😁😂🤣😅😊🙂😉😍😘😜🤪🤗🤔🤩🥳😎😴🤤','😇🥰🥺😡🤬😱😭🙈🙉🙊💀👻🤖🎃','👍👎👏🙌🙏🤝💪🔥✨💥🎉🎊','❤️🧡💛💚💙💜🖤🤍🤎💖💘💝','⭐🌟🌈☀️🌙⚡❄️☔🌊🍀','🎵🎶🎧🎤🎸🥁🎹🎺🎻','🍕🍔🍟🌭🌮🍣🍺🍻🍷🥂','🐶🐱🐼🐸🦄🐧🐢🦖🐙','🚀🛸✈️🚁🚗🏎️🚓🚒','🏆🥇🥈🥉🎯🎮🎲🧩']
|
|
|
|
|
|
return groups.join('').split('');
|
|
|
|
|
|
}, []);
|
2025-08-09 22:00:57 +02:00
|
|
|
|
|
|
|
|
|
|
function emojiToTwemojiUrl(emoji: string): string {
|
|
|
|
|
|
const codePoints = Array.from(emoji).map(ch => ch.codePointAt(0)!.toString(16)).join('-');
|
|
|
|
|
|
// twemoji svg assets
|
|
|
|
|
|
return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoints}.svg`;
|
|
|
|
|
|
}
|
2025-08-09 01:31:55 +02:00
|
|
|
|
const [showBroccoli, setShowBroccoli] = useState<boolean>(false);
|
2025-08-09 21:41:24 +02:00
|
|
|
|
const [flashMap, setFlashMap] = useState<Record<string, boolean>>({});
|
2025-08-08 14:23:18 +02:00
|
|
|
|
const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]);
|
2025-08-08 14:41:05 +02:00
|
|
|
|
const [clock, setClock] = useState<string>(() => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }).format(new Date()));
|
2025-08-08 20:50:11 +02:00
|
|
|
|
const [totalPlays, setTotalPlays] = useState<number>(0);
|
2025-08-08 15:22:15 +02:00
|
|
|
|
const [mediaUrl, setMediaUrl] = useState<string>('');
|
2025-08-09 13:54:38 +02:00
|
|
|
|
const [chaosMode, setChaosMode] = useState<boolean>(false);
|
2025-08-09 14:58:55 +02:00
|
|
|
|
const chaosTimeoutRef = useRef<number | null>(null);
|
2025-08-09 15:28:32 +02:00
|
|
|
|
const chaosModeRef = useRef<boolean>(false);
|
|
|
|
|
|
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
(async () => {
|
|
|
|
|
|
try {
|
2025-08-08 03:17:38 +02:00
|
|
|
|
const c = await fetchChannels();
|
2025-08-07 23:24:56 +02:00
|
|
|
|
setChannels(c);
|
2025-08-08 02:37:53 +02:00
|
|
|
|
const stored = localStorage.getItem('selectedChannel');
|
|
|
|
|
|
if (stored && c.find(x => `${x.guildId}:${x.channelId}` === stored)) {
|
|
|
|
|
|
setSelected(stored);
|
|
|
|
|
|
} else if (c[0]) {
|
|
|
|
|
|
setSelected(`${c[0].guildId}:${c[0].channelId}`);
|
|
|
|
|
|
}
|
2025-08-07 23:24:56 +02:00
|
|
|
|
} catch (e: any) {
|
2025-08-08 03:17:38 +02:00
|
|
|
|
setError(e?.message || 'Fehler beim Laden der Channels');
|
2025-08-07 23:24:56 +02:00
|
|
|
|
}
|
2025-08-09 16:14:46 +02:00
|
|
|
|
try { setIsAdmin(await adminStatus()); } catch {}
|
2025-08-09 17:21:01 +02:00
|
|
|
|
try { const cats = await fetchCategories(); setCategories(cats.categories || []); } catch {}
|
2025-08-09 16:14:46 +02:00
|
|
|
|
try {
|
|
|
|
|
|
const h = await fetch('/api/health').then(r => r.json()).catch(() => null);
|
|
|
|
|
|
if (h && typeof h.totalPlays === 'number') setTotalPlays(h.totalPlays);
|
|
|
|
|
|
} catch {}
|
2025-08-07 23:24:56 +02:00
|
|
|
|
})();
|
2025-08-08 03:17:38 +02:00
|
|
|
|
}, []);
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
2025-08-08 14:41:05 +02:00
|
|
|
|
// Uhrzeit (Berlin) aktualisieren
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const fmt = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' });
|
|
|
|
|
|
const update = () => setClock(fmt.format(new Date()));
|
|
|
|
|
|
const id = setInterval(update, 1000);
|
|
|
|
|
|
update();
|
|
|
|
|
|
return () => clearInterval(id);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-08-08 03:17:38 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
(async () => {
|
2025-08-07 23:24:56 +02:00
|
|
|
|
try {
|
2025-08-08 03:37:54 +02:00
|
|
|
|
const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder;
|
2025-08-09 17:21:01 +02:00
|
|
|
|
const s = await fetchSounds(query, folderParam, activeCategoryId || undefined);
|
2025-08-08 01:40:49 +02:00
|
|
|
|
setSounds(s.items);
|
|
|
|
|
|
setTotal(s.total);
|
2025-08-08 01:56:30 +02:00
|
|
|
|
setFolders(s.folders);
|
2025-08-08 03:17:38 +02:00
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
setError(e?.message || 'Fehler beim Laden der Sounds');
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
2025-08-09 17:21:01 +02:00
|
|
|
|
}, [activeFolder, query, activeCategoryId]);
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
2025-08-08 03:21:01 +02:00
|
|
|
|
// Favoriten aus Cookie laden
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const c = getCookie('favs');
|
|
|
|
|
|
if (c) {
|
|
|
|
|
|
try { setFavs(JSON.parse(c)); } catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// Favoriten persistieren
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
try { setCookie('favs', JSON.stringify(favs)); } catch {}
|
|
|
|
|
|
}, [favs]);
|
|
|
|
|
|
|
2025-08-08 13:17:29 +02:00
|
|
|
|
// Theme anwenden/persistieren
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
document.body.setAttribute('data-theme', theme);
|
2025-08-09 10:58:36 +02:00
|
|
|
|
if (import.meta.env.VITE_BUILD_CHANNEL === 'nightly') {
|
|
|
|
|
|
document.body.setAttribute('data-build', 'nightly');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
document.body.removeAttribute('data-build');
|
|
|
|
|
|
}
|
2025-08-08 13:17:29 +02:00
|
|
|
|
localStorage.setItem('theme', theme);
|
|
|
|
|
|
}, [theme]);
|
|
|
|
|
|
|
2025-08-08 18:51:57 +02:00
|
|
|
|
// Back-to-top Sichtbarkeit
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onScroll = () => setShowTop(window.scrollY > 300);
|
|
|
|
|
|
onScroll();
|
|
|
|
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
|
|
|
|
return () => window.removeEventListener('scroll', onScroll);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-08-09 01:48:47 +02:00
|
|
|
|
// Live-Update für totalPlays Counter
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const updateTotalPlays = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const h = await fetch('/api/health').then(r => r.json()).catch(() => null);
|
|
|
|
|
|
if (h && typeof h.totalPlays === 'number') setTotalPlays(h.totalPlays);
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Sofort beim Start laden
|
|
|
|
|
|
updateTotalPlays();
|
|
|
|
|
|
|
|
|
|
|
|
// Alle 5 Sekunden aktualisieren
|
|
|
|
|
|
const interval = setInterval(updateTotalPlays, 5000);
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-08-08 02:37:53 +02:00
|
|
|
|
useEffect(() => {
|
2025-08-08 13:46:27 +02:00
|
|
|
|
(async () => {
|
|
|
|
|
|
if (selected) {
|
|
|
|
|
|
localStorage.setItem('selectedChannel', selected);
|
|
|
|
|
|
// gespeicherte Lautstärke vom Server laden
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [guildId] = selected.split(':');
|
|
|
|
|
|
const v = await getVolume(guildId);
|
|
|
|
|
|
setVolume(v);
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
2025-08-08 02:37:53 +02:00
|
|
|
|
}, [selected]);
|
|
|
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
|
const filtered = useMemo(() => {
|
|
|
|
|
|
const q = query.trim().toLowerCase();
|
2025-08-08 03:07:35 +02:00
|
|
|
|
if (!q) return sounds;
|
|
|
|
|
|
return sounds.filter((s) => s.name.toLowerCase().includes(q));
|
|
|
|
|
|
}, [sounds, query]);
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
2025-08-08 03:37:54 +02:00
|
|
|
|
const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]);
|
|
|
|
|
|
|
2025-08-09 00:17:07 +02:00
|
|
|
|
function toggleSelect(key: string, on?: boolean) {
|
|
|
|
|
|
setSelectedSet((prev) => ({ ...prev, [key]: typeof on === 'boolean' ? on : !prev[key] }));
|
|
|
|
|
|
}
|
|
|
|
|
|
function clearSelection() {
|
|
|
|
|
|
setSelectedSet({});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 01:56:30 +02:00
|
|
|
|
async function handlePlay(name: string, rel?: string) {
|
2025-08-07 23:24:56 +02:00
|
|
|
|
setError(null);
|
|
|
|
|
|
if (!selected) return setError('Bitte einen Voice-Channel auswählen');
|
|
|
|
|
|
const [guildId, channelId] = selected.split(':');
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
2025-08-08 01:56:30 +02:00
|
|
|
|
await playSound(name, guildId, channelId, volume, rel);
|
2025-08-07 23:24:56 +02:00
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
setError(e?.message || 'Play fehlgeschlagen');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-09 14:58:55 +02:00
|
|
|
|
// CHAOS Mode Funktionen (zufällige Wiedergabe alle 1-3 Minuten)
|
2025-08-09 13:54:38 +02:00
|
|
|
|
const startChaosMode = async () => {
|
|
|
|
|
|
if (!selected || !sounds.length) return;
|
2025-08-09 14:58:55 +02:00
|
|
|
|
|
2025-08-09 13:54:38 +02:00
|
|
|
|
const playRandomSound = async () => {
|
2025-08-09 14:58:55 +02:00
|
|
|
|
const pool = sounds;
|
|
|
|
|
|
if (!pool.length || !selected) return;
|
|
|
|
|
|
const randomSound = pool[Math.floor(Math.random() * pool.length)];
|
2025-08-09 13:54:38 +02:00
|
|
|
|
const [guildId, channelId] = selected.split(':');
|
|
|
|
|
|
try {
|
|
|
|
|
|
await playSound(randomSound.name, guildId, channelId, volume, randomSound.relativePath);
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
console.error('Chaos sound play failed:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-09 14:58:55 +02:00
|
|
|
|
const scheduleNextPlay = async () => {
|
2025-08-09 15:28:32 +02:00
|
|
|
|
if (!chaosModeRef.current) return;
|
2025-08-09 14:58:55 +02:00
|
|
|
|
await playRandomSound();
|
2025-08-09 21:14:44 +02:00
|
|
|
|
const delay = 30_000 + Math.floor(Math.random() * 60_000); // 30-90 Sekunden
|
2025-08-09 14:58:55 +02:00
|
|
|
|
chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, delay);
|
|
|
|
|
|
};
|
2025-08-09 13:54:38 +02:00
|
|
|
|
|
2025-08-09 15:28:32 +02:00
|
|
|
|
// Sofort ersten Sound abspielen
|
|
|
|
|
|
await playRandomSound();
|
|
|
|
|
|
// Nächsten zufällig in 1-3 Minuten planen
|
2025-08-09 21:14:44 +02:00
|
|
|
|
const firstDelay = 30_000 + Math.floor(Math.random() * 60_000);
|
2025-08-09 14:58:55 +02:00
|
|
|
|
chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, firstDelay);
|
2025-08-09 13:54:38 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const stopChaosMode = async () => {
|
2025-08-09 14:58:55 +02:00
|
|
|
|
if (chaosTimeoutRef.current) {
|
|
|
|
|
|
clearTimeout(chaosTimeoutRef.current);
|
|
|
|
|
|
chaosTimeoutRef.current = null;
|
2025-08-09 13:54:38 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Alle Sounds stoppen (wie Panic Button)
|
|
|
|
|
|
if (selected) {
|
|
|
|
|
|
const [guildId] = selected.split(':');
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' });
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
console.error('Chaos stop failed:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const toggleChaosMode = async () => {
|
|
|
|
|
|
if (chaosMode) {
|
|
|
|
|
|
setChaosMode(false);
|
|
|
|
|
|
await stopChaosMode();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setChaosMode(true);
|
|
|
|
|
|
await startChaosMode();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Cleanup bei Komponenten-Unmount
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
2025-08-09 14:58:55 +02:00
|
|
|
|
if (chaosTimeoutRef.current) {
|
|
|
|
|
|
clearTimeout(chaosTimeoutRef.current);
|
2025-08-09 13:54:38 +02:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
|
return (
|
2025-08-08 14:55:12 +02:00
|
|
|
|
<ErrorBoundary>
|
2025-08-09 16:36:22 +02:00
|
|
|
|
<div className="page-container" data-theme={theme}>
|
2025-08-09 01:22:59 +02:00
|
|
|
|
{/* Floating Broccoli for 420 Theme */}
|
2025-08-09 01:31:55 +02:00
|
|
|
|
{theme === '420' && showBroccoli && (
|
2025-08-09 01:22:59 +02:00
|
|
|
|
<>
|
|
|
|
|
|
<div className="broccoli">🥦</div>
|
|
|
|
|
|
<div className="broccoli">🥦</div>
|
|
|
|
|
|
<div className="broccoli">🥦</div>
|
|
|
|
|
|
<div className="broccoli">🥦</div>
|
|
|
|
|
|
<div className="broccoli">🥦</div>
|
|
|
|
|
|
<div className="broccoli">🥦</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2025-08-09 00:41:13 +02:00
|
|
|
|
<header className="flex items-center justify-between p-6">
|
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
|
<div>
|
2025-08-09 16:14:46 +02:00
|
|
|
|
<h1 className="text-4xl font-bold">
|
2025-08-09 15:53:51 +02:00
|
|
|
|
Jukebox 420
|
2025-08-09 16:14:46 +02:00
|
|
|
|
{import.meta.env.VITE_BUILD_CHANNEL === 'nightly' && (
|
|
|
|
|
|
<div className="text-sm font-normal mt-1 opacity-70">
|
|
|
|
|
|
v{import.meta.env.VITE_APP_VERSION || ''}
|
2025-08-09 15:53:51 +02:00
|
|
|
|
<span className="ml-2" style={{ color: '#ff4d4f' }}>• Nightly</span>
|
2025-08-09 16:14:46 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-08-09 15:53:51 +02:00
|
|
|
|
</h1>
|
2025-08-09 00:41:13 +02:00
|
|
|
|
<p className="text-7xl font-bold mt-2">{clock}</p>
|
|
|
|
|
|
</div>
|
2025-08-08 19:48:21 +02:00
|
|
|
|
</div>
|
2025-08-09 00:41:13 +02:00
|
|
|
|
<div className="flex items-center space-x-8">
|
|
|
|
|
|
<div className="text-center">
|
2025-08-09 01:48:47 +02:00
|
|
|
|
<p className="text-lg text-gray-400">Sounds</p>
|
2025-08-09 00:41:13 +02:00
|
|
|
|
<p className="text-2xl font-bold">{total}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-center">
|
2025-08-09 01:48:47 +02:00
|
|
|
|
<p className="text-lg text-gray-400">Played</p>
|
2025-08-09 00:41:13 +02:00
|
|
|
|
<p className="text-2xl font-bold">{totalPlays}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center space-x-4">
|
|
|
|
|
|
<button className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async () => {
|
|
|
|
|
|
try { const res = await fetch('/api/sounds'); const data = await res.json(); const items = data?.items || []; if (!items.length || !selected) return; const rnd = items[Math.floor(Math.random()*items.length)]; const [guildId, channelId] = selected.split(':'); await playSound(rnd.name, guildId, channelId, volume, rnd.relativePath);} catch {}
|
|
|
|
|
|
}}>Random</button>
|
2025-08-09 15:25:06 +02:00
|
|
|
|
<button
|
2025-08-09 14:52:27 +02:00
|
|
|
|
className={`font-bold py-3 px-6 rounded-lg transition duration-300 ${
|
|
|
|
|
|
chaosMode
|
|
|
|
|
|
? 'chaos-rainbow text-white'
|
|
|
|
|
|
: 'bg-gray-700 hover:bg-gray-600 text-white'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={toggleChaosMode}
|
|
|
|
|
|
>
|
2025-08-09 20:01:52 +02:00
|
|
|
|
Partymode
|
2025-08-09 14:52:27 +02:00
|
|
|
|
</button>
|
2025-08-09 15:25:06 +02:00
|
|
|
|
<button className="bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async () => { setChaosMode(false); await stopChaosMode(); }}>Panic</button>
|
2025-08-08 21:21:23 +02:00
|
|
|
|
</div>
|
2025-08-08 14:23:18 +02:00
|
|
|
|
</div>
|
2025-08-08 21:21:23 +02:00
|
|
|
|
</header>
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
2025-08-08 21:21:23 +02:00
|
|
|
|
<div className="control-panel rounded-xl p-6 mb-8">
|
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-center">
|
|
|
|
|
|
<div className="relative">
|
2025-08-08 23:57:50 +02:00
|
|
|
|
<input className="input-field pl-10 with-left-icon" placeholder="Nach Sounds suchen..." value={query} onChange={(e)=>setQuery(e.target.value)} />
|
2025-08-08 21:21:23 +02:00
|
|
|
|
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2" style={{color:'var(--text-secondary)'}}>search</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<CustomSelect channels={channels} value={selected} onChange={setSelected} />
|
|
|
|
|
|
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2" style={{color:'var(--text-secondary)'}}>folder_special</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center space-x-3">
|
|
|
|
|
|
<span className="material-icons" style={{color:'var(--text-secondary)'}}>volume_up</span>
|
2025-08-08 23:40:26 +02:00
|
|
|
|
<input
|
2025-08-09 00:09:03 +02:00
|
|
|
|
className="volume-slider w-full h-2 rounded-lg appearance-none cursor-pointer"
|
2025-08-08 23:40:26 +02:00
|
|
|
|
type="range" min={0} max={1} step={0.01}
|
|
|
|
|
|
value={volume}
|
|
|
|
|
|
onChange={async (e)=>{
|
|
|
|
|
|
const v = parseFloat(e.target.value);
|
|
|
|
|
|
setVolume(v);
|
|
|
|
|
|
// CSS-Variable setzen, um die Füllbreite zu steuern
|
|
|
|
|
|
const percent = `${Math.round(v * 100)}%`;
|
|
|
|
|
|
try { (e.target as HTMLInputElement).style.setProperty('--_fill', percent); } catch {}
|
|
|
|
|
|
if(selected){ const [guildId]=selected.split(':'); try{ await setVolumeLive(guildId, v);}catch{} }
|
|
|
|
|
|
}}
|
|
|
|
|
|
// Initiale Füllbreite, falls State geladen ist
|
|
|
|
|
|
style={{ ['--_fill' as any]: `${Math.round(volume*100)}%` }}
|
|
|
|
|
|
/>
|
2025-08-08 21:21:23 +02:00
|
|
|
|
<span className="text-sm font-semibold w-8 text-center" style={{color:'var(--text-secondary)'}}>{Math.round(volume*100)}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="relative md:col-span-2 lg:col-span-1">
|
2025-08-08 23:57:50 +02:00
|
|
|
|
<input className="input-field pl-10 with-left-icon" placeholder="MP3 URL..." value={mediaUrl} onChange={(e)=>setMediaUrl(e.target.value)} onKeyDown={async (e)=>{ if(e.key==='Enter'){ if(!selected){ setError('Bitte Voice-Channel wählen'); setInfo(null); return;} const [guildId,channelId]=selected.split(':'); try{ await playUrl(mediaUrl,guildId,channelId,volume); setError(null); setInfo('MP3 heruntergeladen und abgespielt.'); }catch(err:any){ setInfo(null); setError(err?.message||'Download fehlgeschlagen'); } } }} />
|
2025-08-08 21:21:23 +02:00
|
|
|
|
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2" style={{color:'var(--text-secondary)'}}>link</span>
|
|
|
|
|
|
<button className="absolute right-0 top-0 h-full px-4 text-white flex items-center rounded-r-lg transition-all font-semibold" style={{background:'var(--accent-green)'}} onClick={async ()=>{ if(!selected){ setError('Bitte Voice-Channel wählen'); setInfo(null); return;} const [guildId,channelId]=selected.split(':'); try{ await playUrl(mediaUrl,guildId,channelId,volume); setError(null); setInfo('MP3 heruntergeladen und abgespielt.'); }catch(e:any){ setInfo(null); setError(e?.message||'Download fehlgeschlagen'); } }}>
|
|
|
|
|
|
<span className="material-icons text-sm mr-1">file_download</span>
|
|
|
|
|
|
Download
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center space-x-3 lg:col-span-2">
|
|
|
|
|
|
<div className="relative flex-grow">
|
2025-08-09 00:59:13 +02:00
|
|
|
|
<select className="input-field appearance-none pl-10" value={theme} onChange={(e)=>setTheme(e.target.value)}>
|
|
|
|
|
|
<option value="dark">Dark</option>
|
2025-08-09 01:06:51 +02:00
|
|
|
|
<option value="rainbow">Rainbow</option>
|
2025-08-09 01:20:58 +02:00
|
|
|
|
<option value="420">420</option>
|
2025-08-09 00:59:13 +02:00
|
|
|
|
</select>
|
2025-08-08 21:21:23 +02:00
|
|
|
|
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2" style={{color:'var(--text-secondary)'}}>palette</span>
|
|
|
|
|
|
<span className="material-icons absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" style={{color:'var(--text-secondary)'}}>unfold_more</span>
|
|
|
|
|
|
</div>
|
2025-08-09 01:31:55 +02:00
|
|
|
|
{theme === '420' && (
|
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
id="broccoli-toggle"
|
|
|
|
|
|
checked={showBroccoli}
|
|
|
|
|
|
onChange={(e) => setShowBroccoli(e.target.checked)}
|
|
|
|
|
|
className="w-4 h-4 accent-green-500"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label htmlFor="broccoli-toggle" className="text-sm font-medium" style={{color:'var(--text-secondary)'}}>
|
|
|
|
|
|
Brokkoli?
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-08-08 21:21:23 +02:00
|
|
|
|
</div>
|
2025-08-08 14:23:18 +02:00
|
|
|
|
</div>
|
2025-08-08 21:21:23 +02:00
|
|
|
|
<div className="mt-6" style={{borderTop:'1px solid var(--border-color)', paddingTop:'1.5rem'}}>
|
2025-08-09 00:17:07 +02:00
|
|
|
|
<div className="flex items-center gap-4 justify-between flex-wrap">
|
|
|
|
|
|
{!isAdmin ? (
|
2025-08-08 21:21:23 +02:00
|
|
|
|
<>
|
|
|
|
|
|
<div className="relative w-full sm:w-auto" style={{maxWidth:'15%'}}>
|
2025-08-09 15:45:10 +02:00
|
|
|
|
<input
|
|
|
|
|
|
className="input-field pl-10 with-left-icon"
|
|
|
|
|
|
placeholder="Admin Passwort"
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
value={adminPwd}
|
|
|
|
|
|
onChange={(e)=>setAdminPwd(e.target.value)}
|
|
|
|
|
|
onKeyDown={async (e)=>{
|
|
|
|
|
|
if(e.key === 'Enter') {
|
|
|
|
|
|
const ok = await adminLogin(adminPwd);
|
|
|
|
|
|
if(ok) {
|
|
|
|
|
|
setIsAdmin(true);
|
|
|
|
|
|
setAdminPwd('');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert('Login fehlgeschlagen');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2025-08-08 21:21:23 +02:00
|
|
|
|
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2" style={{color:'var(--text-secondary)'}}>lock</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button className="bg-gray-800 text-white hover:bg-black font-semibold py-2 px-5 rounded-lg transition-all w-full sm:w-auto" style={{maxWidth:'15%'}} onClick={async ()=>{ const ok=await adminLogin(adminPwd); if(ok){ setIsAdmin(true); setAdminPwd(''); } else alert('Login fehlgeschlagen'); }}>Login</button>
|
|
|
|
|
|
</>
|
2025-08-09 00:17:07 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex items-center gap-3 w-full">
|
2025-08-09 01:54:59 +02:00
|
|
|
|
<span className="bg-gray-700 text-white font-bold py-3 px-6 rounded-lg">Ausgewählt: {selectedCount}</span>
|
2025-08-09 00:17:07 +02:00
|
|
|
|
{selectedCount > 0 && (
|
|
|
|
|
|
<button
|
2025-08-09 01:50:25 +02:00
|
|
|
|
className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300"
|
2025-08-09 00:17:07 +02:00
|
|
|
|
onClick={async ()=>{
|
|
|
|
|
|
try {
|
|
|
|
|
|
const toDelete = Object.entries(selectedSet).filter(([,v])=>v).map(([k])=>k);
|
|
|
|
|
|
await adminDelete(toDelete);
|
|
|
|
|
|
clearSelection();
|
|
|
|
|
|
const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder);
|
|
|
|
|
|
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
|
|
|
|
|
|
} catch (e:any) { setError(e?.message||'Löschen fehlgeschlagen'); }
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Löschen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{selectedCount === 1 && (
|
|
|
|
|
|
<RenameInline onSubmit={async (newName)=>{
|
|
|
|
|
|
const from = Object.entries(selectedSet).find(([,v])=>v)?.[0];
|
|
|
|
|
|
if(!from) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await adminRename(from, newName);
|
|
|
|
|
|
clearSelection();
|
|
|
|
|
|
const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder);
|
|
|
|
|
|
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
|
|
|
|
|
|
} catch (e:any) { setError(e?.message||'Umbenennen fehlgeschlagen'); }
|
|
|
|
|
|
}} />
|
|
|
|
|
|
)}
|
2025-08-09 17:21:01 +02:00
|
|
|
|
{/* Kategorien-Zuweisung */}
|
|
|
|
|
|
{selectedCount > 0 && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<select className="input-field" value={assignCategoryId} onChange={(e)=>setAssignCategoryId(e.target.value)} style={{maxWidth:200}}>
|
|
|
|
|
|
<option value="">Kategorie wählen…</option>
|
|
|
|
|
|
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300"
|
|
|
|
|
|
onClick={async ()=>{
|
|
|
|
|
|
try{
|
|
|
|
|
|
const files = Object.entries(selectedSet).filter(([,v])=>v).map(([k])=>k);
|
|
|
|
|
|
if(!assignCategoryId){ setError('Bitte Kategorie wählen'); return; }
|
|
|
|
|
|
await assignCategories(files, [assignCategoryId], []);
|
|
|
|
|
|
setInfo('Kategorie zugewiesen'); setError(null);
|
|
|
|
|
|
const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder, activeCategoryId || undefined);
|
|
|
|
|
|
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
|
|
|
|
|
|
}catch(e:any){ setError(e?.message||'Zuweisung fehlgeschlagen'); setInfo(null); }
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Zu Kategorie</button>
|
2025-08-09 17:27:17 +02:00
|
|
|
|
|
2025-08-09 17:30:21 +02:00
|
|
|
|
{/* Custom Badge Picker */}
|
|
|
|
|
|
<div style={{ position:'relative' }}>
|
|
|
|
|
|
<button
|
2025-08-09 21:29:11 +02:00
|
|
|
|
ref={emojiTriggerRef}
|
2025-08-09 17:30:21 +02:00
|
|
|
|
className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300"
|
2025-08-09 21:29:11 +02:00
|
|
|
|
onClick={()=> {
|
|
|
|
|
|
try{
|
|
|
|
|
|
const r = emojiTriggerRef.current?.getBoundingClientRect();
|
|
|
|
|
|
if(r){ setEmojiPos({ left: r.left, top: r.bottom + 8 }); }
|
|
|
|
|
|
}catch{}
|
|
|
|
|
|
setShowEmojiPicker(v=>!v);
|
|
|
|
|
|
}}
|
2025-08-09 17:30:21 +02:00
|
|
|
|
>Custom Emoji</button>
|
2025-08-09 21:36:06 +02:00
|
|
|
|
{showEmojiPicker && typeof document !== 'undefined' && ReactDOM.createPortal(
|
|
|
|
|
|
<div ref={emojiPickerRef as any} className="emoji-picker" style={{ position:'fixed', left: emojiPos.left, top: emojiPos.top, zIndex: 300000 }}>
|
2025-08-09 17:30:21 +02:00
|
|
|
|
{EMOJIS.map((e, i)=> (
|
|
|
|
|
|
<button key={i} onClick={async ()=>{
|
|
|
|
|
|
try{
|
|
|
|
|
|
const files = Object.entries(selectedSet).filter(([,v])=>v).map(([k])=>k);
|
|
|
|
|
|
await assignBadges(files, [e], []);
|
|
|
|
|
|
setShowEmojiPicker(false);
|
|
|
|
|
|
setInfo('Badge gesetzt'); setError(null);
|
|
|
|
|
|
const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder, activeCategoryId || undefined);
|
|
|
|
|
|
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
|
|
|
|
|
|
}catch(err:any){ setError(err?.message||'Badge-Update fehlgeschlagen'); setInfo(null); }
|
2025-08-09 22:00:57 +02:00
|
|
|
|
}}>
|
|
|
|
|
|
<img alt={e} src={emojiToTwemojiUrl(e)} />
|
|
|
|
|
|
</button>
|
2025-08-09 17:30:21 +02:00
|
|
|
|
))}
|
2025-08-09 21:36:06 +02:00
|
|
|
|
</div>,
|
|
|
|
|
|
document.body
|
2025-08-09 17:30:21 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-08-09 17:32:38 +02:00
|
|
|
|
|
2025-08-09 21:12:02 +02:00
|
|
|
|
<button
|
|
|
|
|
|
className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300"
|
|
|
|
|
|
onClick={async ()=>{
|
|
|
|
|
|
try{
|
|
|
|
|
|
const files = Object.entries(selectedSet).filter(([,v])=>v).map(([k])=>k);
|
|
|
|
|
|
await clearBadges(files);
|
|
|
|
|
|
setInfo('Alle Custom-Badges entfernt'); setError(null);
|
|
|
|
|
|
const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder, activeCategoryId || undefined);
|
|
|
|
|
|
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
|
|
|
|
|
|
}catch(err:any){ setError(err?.message||'Badge-Entfernung fehlgeschlagen'); setInfo(null); }
|
|
|
|
|
|
}}
|
|
|
|
|
|
>Badges entfernen</button>
|
2025-08-09 17:21:01 +02:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-08-09 00:17:07 +02:00
|
|
|
|
<div className="flex-1" />
|
2025-08-09 17:21:01 +02:00
|
|
|
|
|
2025-08-09 17:30:21 +02:00
|
|
|
|
{/* Kategorien: anlegen/umbenennen/löschen */}
|
2025-08-09 17:21:01 +02:00
|
|
|
|
<input className="input-field" placeholder="Neue Kategorie" value={newCategoryName} onChange={(e)=>setNewCategoryName(e.target.value)} style={{maxWidth:200}} />
|
|
|
|
|
|
<button className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async()=>{
|
|
|
|
|
|
try{
|
|
|
|
|
|
const n = newCategoryName.trim(); if(!n){ setError('Name fehlt'); return; }
|
|
|
|
|
|
await createCategory(n);
|
|
|
|
|
|
setNewCategoryName('');
|
|
|
|
|
|
const cats = await fetchCategories(); setCategories(cats.categories || []);
|
|
|
|
|
|
setInfo('Kategorie erstellt'); setError(null);
|
|
|
|
|
|
}catch(e:any){ setError(e?.message||'Anlegen fehlgeschlagen'); setInfo(null); }
|
|
|
|
|
|
}}>Anlegen</button>
|
|
|
|
|
|
|
2025-08-09 17:30:21 +02:00
|
|
|
|
<select className="input-field" value={editingCategoryId} onChange={(e)=>{ setEditingCategoryId(e.target.value); const c = categories.find(x=>x.id===e.target.value); setEditingCategoryName(c?.name||''); }} style={{maxWidth:200}}>
|
|
|
|
|
|
<option value="">Kategorie wählen…</option>
|
|
|
|
|
|
{categories.map(c=> <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<input className="input-field" placeholder="Neuer Name" value={editingCategoryName} onChange={(e)=>setEditingCategoryName(e.target.value)} style={{maxWidth:200}} />
|
|
|
|
|
|
<button className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async()=>{
|
|
|
|
|
|
try{
|
|
|
|
|
|
if(!editingCategoryId){ setError('Bitte Kategorie wählen'); return; }
|
|
|
|
|
|
await updateCategory(editingCategoryId, { name: editingCategoryName.trim() });
|
|
|
|
|
|
const cats = await fetchCategories(); setCategories(cats.categories || []);
|
|
|
|
|
|
setInfo('Kategorie umbenannt'); setError(null);
|
|
|
|
|
|
}catch(e:any){ setError(e?.message||'Umbenennen fehlgeschlagen'); setInfo(null); }
|
|
|
|
|
|
}}>Umbenennen</button>
|
|
|
|
|
|
<button className="bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async()=>{
|
|
|
|
|
|
try{
|
|
|
|
|
|
if(!editingCategoryId){ setError('Bitte Kategorie wählen'); return; }
|
|
|
|
|
|
await deleteCategory(editingCategoryId);
|
|
|
|
|
|
setEditingCategoryId(''); setEditingCategoryName('');
|
|
|
|
|
|
const cats = await fetchCategories(); setCategories(cats.categories || []);
|
|
|
|
|
|
setInfo('Kategorie gelöscht'); setError(null);
|
|
|
|
|
|
}catch(e:any){ setError(e?.message||'Löschen fehlgeschlagen'); setInfo(null); }
|
|
|
|
|
|
}}>Löschen</button>
|
|
|
|
|
|
|
2025-08-09 01:50:25 +02:00
|
|
|
|
<button className="bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={async ()=>{ try{ await adminLogout(); setIsAdmin(false); clearSelection(); } catch{} }}>Logout</button>
|
2025-08-09 00:17:07 +02:00
|
|
|
|
</div>
|
2025-08-08 14:23:18 +02:00
|
|
|
|
)}
|
2025-08-08 03:21:01 +02:00
|
|
|
|
</div>
|
2025-08-08 21:21:23 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-09 15:46:12 +02:00
|
|
|
|
{error && <div className="error mb-4">{error}</div>}
|
|
|
|
|
|
{info && <div className="badge mb-4" style={{ background:'rgba(34,197,94,.18)', borderColor:'rgba(34,197,94,.35)' }}>{info}</div>}
|
|
|
|
|
|
|
2025-08-08 21:21:23 +02:00
|
|
|
|
<div className="bg-transparent mb-8">
|
|
|
|
|
|
<div className="flex flex-wrap gap-3 text-sm">
|
|
|
|
|
|
<button className={`tag-btn ${activeFolder==='__favs__'?'active':''}`} onClick={()=>setActiveFolder('__favs__')}>Favoriten ({favCount})</button>
|
2025-08-09 10:40:34 +02:00
|
|
|
|
{folders.map(f=> {
|
|
|
|
|
|
const displayName = f.name.replace(/\s*\(\d+\)\s*$/, '');
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={f.key}
|
|
|
|
|
|
className={`tag-btn ${activeFolder===f.key?'active':''}`}
|
|
|
|
|
|
onClick={async ()=>{
|
|
|
|
|
|
setActiveFolder(f.key);
|
2025-08-09 17:21:01 +02:00
|
|
|
|
const resp=await fetchSounds(undefined, f.key, activeCategoryId || undefined);
|
2025-08-09 10:40:34 +02:00
|
|
|
|
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{displayName} ({f.count})
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-08-08 21:21:23 +02:00
|
|
|
|
</div>
|
2025-08-09 19:52:45 +02:00
|
|
|
|
{categories.length > 0 && (
|
|
|
|
|
|
<div className="flex flex-wrap gap-3 text-sm mt-3">
|
|
|
|
|
|
{categories.map(cat => (
|
2025-08-09 19:58:58 +02:00
|
|
|
|
<button
|
|
|
|
|
|
key={cat.id}
|
|
|
|
|
|
className={`tag-btn ${activeCategoryId===cat.id?'active':''}`}
|
|
|
|
|
|
onClick={()=> setActiveCategoryId(prev => (prev === cat.id ? '' : cat.id))}
|
|
|
|
|
|
>
|
|
|
|
|
|
{cat.name}
|
|
|
|
|
|
</button>
|
2025-08-09 19:52:45 +02:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-08-08 21:21:23 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-09 02:12:52 +02:00
|
|
|
|
<main className="sounds-flow">
|
2025-08-08 21:21:23 +02:00
|
|
|
|
{(activeFolder === '__favs__' ? filtered.filter((s) => !!favs[s.relativePath ?? s.fileName]) : filtered).map((s) => {
|
|
|
|
|
|
const key = `${s.relativePath ?? s.fileName}`;
|
|
|
|
|
|
const isFav = !!favs[key];
|
|
|
|
|
|
return (
|
2025-08-09 00:17:07 +02:00
|
|
|
|
<div key={`${s.fileName}-${s.name}`} className="sound-wrap">
|
|
|
|
|
|
{isAdmin && (
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
className="select-check"
|
|
|
|
|
|
checked={!!selectedSet[key]}
|
|
|
|
|
|
onChange={(e)=>{ e.stopPropagation(); toggleSelect(key, e.target.checked); }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2025-08-09 21:41:24 +02:00
|
|
|
|
<div className={`sound-btn group rounded-xl flex items-center justify-between p-3 cursor-pointer ${flashMap[key] ? 'rainbow-flash' : ''}`}
|
|
|
|
|
|
onClick={async ()=>{
|
|
|
|
|
|
// Rainbow-Flash via State, damit Re-Render die Klasse erhält
|
|
|
|
|
|
if (theme === 'rainbow') {
|
|
|
|
|
|
setFlashMap(prev => ({ ...prev, [key]: true }));
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
setFlashMap(prev => { const n = { ...prev }; delete n[key]; return n; });
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
}
|
2025-08-09 21:18:01 +02:00
|
|
|
|
await handlePlay(s.name, s.relativePath);
|
|
|
|
|
|
}}>
|
2025-08-09 17:27:17 +02:00
|
|
|
|
<span className="text-sm font-medium truncate pr-2">
|
|
|
|
|
|
{s.name}
|
|
|
|
|
|
{Array.isArray((s as any).badges) && (s as any).badges!.map((b:string, i:number)=> (
|
|
|
|
|
|
<span key={i} style={{ marginLeft: 6, opacity:.9 }}>{b==='new'?'🆕': b==='rocket'?'🚀': b}</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</span>
|
2025-08-09 00:17:07 +02:00
|
|
|
|
<div className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
|
|
|
|
<button className="text-gray-400 hover:text-[var(--accent-blue)]" onClick={(e)=>{e.stopPropagation(); setFavs(prev=>({ ...prev, [key]: !prev[key] }));}}><span className="material-icons text-xl">{isFav?'star':'star_border'}</span></button>
|
|
|
|
|
|
</div>
|
2025-08-08 21:21:23 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</main>
|
2025-08-09 16:43:05 +02:00
|
|
|
|
{/* Footer: Version/Channel */}
|
|
|
|
|
|
<footer className="footer-info">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
v{import.meta.env.VITE_APP_VERSION || ''}
|
|
|
|
|
|
{import.meta.env.VITE_BUILD_CHANNEL === 'nightly' && (
|
|
|
|
|
|
<span className="ml-2">• Nightly</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</footer>
|
2025-08-08 21:21:23 +02:00
|
|
|
|
</div>
|
2025-08-08 18:51:57 +02:00
|
|
|
|
{showTop && (
|
2025-08-08 21:21:23 +02:00
|
|
|
|
<button type="button" className="back-to-top" aria-label="Nach oben" onClick={()=>window.scrollTo({top:0, behavior:'smooth'})}>↑ Top</button>
|
2025-08-08 18:51:57 +02:00
|
|
|
|
)}
|
2025-08-08 14:55:12 +02:00
|
|
|
|
</ErrorBoundary>
|
2025-08-07 23:24:56 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 13:14:27 +02:00
|
|
|
|
type SelectProps = {
|
|
|
|
|
|
channels: VoiceChannelInfo[];
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
onChange: (v: string) => void;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function CustomSelect({ channels, value, onChange }: SelectProps) {
|
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
const ref = useRef<HTMLDivElement | null>(null);
|
2025-08-08 18:22:37 +02:00
|
|
|
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
|
|
const [menuPos, setMenuPos] = useState<{ left: number; top: number; width: number }>({ left: 0, top: 0, width: 0 });
|
2025-08-08 13:14:27 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const close = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
|
|
|
|
|
window.addEventListener('click', close);
|
|
|
|
|
|
return () => window.removeEventListener('click', close);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-08-08 18:22:37 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open) return;
|
|
|
|
|
|
const update = () => {
|
|
|
|
|
|
const el = triggerRef.current;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
const r = el.getBoundingClientRect();
|
|
|
|
|
|
setMenuPos({ left: Math.round(r.left), top: Math.round(r.bottom + 6), width: Math.round(r.width) });
|
|
|
|
|
|
};
|
|
|
|
|
|
update();
|
|
|
|
|
|
window.addEventListener('resize', update);
|
|
|
|
|
|
window.addEventListener('scroll', update, true);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener('resize', update);
|
|
|
|
|
|
window.removeEventListener('scroll', update, true);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
2025-08-08 13:14:27 +02:00
|
|
|
|
const current = channels.find(c => `${c.guildId}:${c.channelId}` === value);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="control select custom-select" ref={ref}>
|
2025-08-08 18:22:37 +02:00
|
|
|
|
<button ref={triggerRef} type="button" className="select-trigger" onClick={() => setOpen(v => !v)}>
|
2025-08-08 13:14:27 +02:00
|
|
|
|
{current ? `${current.guildName} – ${current.channelName}` : 'Channel wählen'}
|
|
|
|
|
|
<span className="chev">▾</span>
|
|
|
|
|
|
</button>
|
2025-08-08 18:22:37 +02:00
|
|
|
|
{open && typeof document !== 'undefined' && ReactDOM.createPortal(
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="select-menu"
|
|
|
|
|
|
style={{ position: 'fixed', left: menuPos.left, top: menuPos.top, width: menuPos.width, zIndex: 30000 }}
|
|
|
|
|
|
>
|
2025-08-08 13:14:27 +02:00
|
|
|
|
{channels.map((c) => {
|
|
|
|
|
|
const v = `${c.guildId}:${c.channelId}`;
|
|
|
|
|
|
const active = v === value;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
key={v}
|
|
|
|
|
|
className={`select-item ${active ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => { onChange(v); setOpen(false); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{c.guildName} – {c.channelName}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-08-08 18:22:37 +02:00
|
|
|
|
</div>,
|
|
|
|
|
|
document.body
|
2025-08-08 13:14:27 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2025-08-08 01:56:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 14:55:12 +02:00
|
|
|
|
// Einfache ErrorBoundary, damit die Seite nicht blank wird und Fehler sichtbar sind
|
|
|
|
|
|
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { error?: Error }>{
|
|
|
|
|
|
constructor(props: { children: React.ReactNode }) {
|
|
|
|
|
|
super(props);
|
|
|
|
|
|
this.state = { error: undefined };
|
|
|
|
|
|
}
|
|
|
|
|
|
static getDerivedStateFromError(error: Error) { return { error }; }
|
|
|
|
|
|
componentDidCatch(error: Error, info: any) { console.error('UI-ErrorBoundary:', error, info); }
|
|
|
|
|
|
render() {
|
|
|
|
|
|
if (this.state.error) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: 20 }}>
|
|
|
|
|
|
<h2>Es ist ein Fehler aufgetreten</h2>
|
|
|
|
|
|
<pre style={{ whiteSpace: 'pre-wrap' }}>{String(this.state.error.message || this.state.error)}</pre>
|
|
|
|
|
|
<button type="button" onClick={() => this.setState({ error: undefined })}>Zurück</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return this.props.children as any;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-08 15:01:53 +02:00
|
|
|
|
// Inline-Komponente für Umbenennen (nur bei genau 1 Selektion sichtbar)
|
|
|
|
|
|
type RenameInlineProps = { onSubmit: (newName: string) => void | Promise<void> };
|
|
|
|
|
|
function RenameInline({ onSubmit }: RenameInlineProps) {
|
|
|
|
|
|
const [val, setVal] = useState('');
|
|
|
|
|
|
async function submit() {
|
|
|
|
|
|
const n = val.trim();
|
|
|
|
|
|
if (!n) return;
|
|
|
|
|
|
await onSubmit(n);
|
|
|
|
|
|
setVal('');
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
2025-08-09 01:03:36 +02:00
|
|
|
|
<input
|
|
|
|
|
|
value={val}
|
|
|
|
|
|
onChange={(e) => setVal(e.target.value)}
|
|
|
|
|
|
placeholder="Neuer Name"
|
|
|
|
|
|
onKeyDown={(e) => { if (e.key === 'Enter') void submit(); }}
|
|
|
|
|
|
style={{ color: '#000000' }}
|
|
|
|
|
|
/>
|
2025-08-09 01:50:25 +02:00
|
|
|
|
<button type="button" className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition duration-300" onClick={() => void submit()}>Umbenennen</button>
|
2025-08-08 15:01:53 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|