feat: complete apple ui redesign on stable
This commit is contained in:
parent
584bb5ca4f
commit
187905d22b
4 changed files with 849 additions and 1292 deletions
8
.github/workflows/docker-build.yml
vendored
8
.github/workflows/docker-build.yml
vendored
|
|
@ -31,12 +31,16 @@ jobs:
|
||||||
echo "tag=main" >> $GITHUB_OUTPUT
|
echo "tag=main" >> $GITHUB_OUTPUT
|
||||||
echo "version=1.1.0" >> $GITHUB_OUTPUT
|
echo "version=1.1.0" >> $GITHUB_OUTPUT
|
||||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||||
|
elif [[ "${{ github.ref_name }}" == "feature/nightly" ]] || [[ "${{ github.ref_name }}" == "nightly" ]]; then
|
||||||
|
echo "tag=nightly" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=1.1.0-nightly" >> $GITHUB_OUTPUT
|
||||||
|
echo "channel=nightly" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
# Ersetze Slashes durch Bindestriche für gültige Docker Tags
|
# Ersetze Slashes durch Bindestriche für gültige Docker Tags
|
||||||
CLEAN_TAG=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
|
CLEAN_TAG=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
|
||||||
echo "tag=$CLEAN_TAG" >> $GITHUB_OUTPUT
|
echo "tag=$CLEAN_TAG" >> $GITHUB_OUTPUT
|
||||||
echo "version=1.1.0-nightly" >> $GITHUB_OUTPUT
|
echo "version=1.1.0-dev" >> $GITHUB_OUTPUT
|
||||||
echo "channel=nightly" >> $GITHUB_OUTPUT
|
echo "channel=dev" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Nur auf main: auch :latest tag pushen
|
# Nur auf main: auch :latest tag pushen
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>Soundboard</title>
|
<title>Soundboard</title>
|
||||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
<!-- No tailwind script injection to avoid collision with our custom CSS structural layout -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
|
||||||
|
rel="stylesheet" />
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="p-4 sm:p-8">
|
|
||||||
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
796
web/src/App.tsx
796
web/src/App.tsx
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl, fetchCategories, createCategory, assignCategories, clearBadges, updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel } from './api';
|
import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl, fetchCategories, createCategory, assignCategories, clearBadges, updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel } from './api';
|
||||||
import type { VoiceChannelInfo, Sound, Category } from './types';
|
import type { VoiceChannelInfo, Sound, Category } from './types';
|
||||||
import { getCookie, setCookie } from './cookies';
|
import { getCookie, setCookie } from './cookies';
|
||||||
|
|
@ -13,64 +12,35 @@ export default function App() {
|
||||||
const [activeCategoryId, setActiveCategoryId] = useState<string>('');
|
const [activeCategoryId, setActiveCategoryId] = useState<string>('');
|
||||||
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [fuzzy, setFuzzy] = useState<boolean>(false);
|
|
||||||
const [selected, setSelected] = useState<string>('');
|
const [selected, setSelected] = useState<string>('');
|
||||||
const selectedRef = useRef<string>('');
|
const selectedRef = useRef<string>('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [notification, setNotification] = useState<{ msg: string, type: 'info' | 'error' } | null>(null);
|
||||||
const [info, setInfo] = useState<string | null>(null);
|
|
||||||
const [showTop, setShowTop] = useState<boolean>(false);
|
|
||||||
const [volume, setVolume] = useState<number>(1);
|
const [volume, setVolume] = useState<number>(1);
|
||||||
const [favs, setFavs] = useState<Record<string, boolean>>({});
|
const [favs, setFavs] = useState<Record<string, boolean>>({});
|
||||||
const [theme, setTheme] = useState<string>(() => localStorage.getItem('theme') || 'dark');
|
const [theme, setTheme] = useState<string>(() => localStorage.getItem('theme') || 'dark');
|
||||||
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
||||||
const [adminPwd, setAdminPwd] = useState<string>('');
|
|
||||||
const [selectedSet, setSelectedSet] = useState<Record<string, boolean>>({});
|
|
||||||
const [assignCategoryId, setAssignCategoryId] = useState<string>('');
|
|
||||||
const [newCategoryName, setNewCategoryName] = useState<string>('');
|
|
||||||
const [editingCategoryId, setEditingCategoryId] = useState<string>('');
|
|
||||||
const [editingCategoryName, setEditingCategoryName] = useState<string>('');
|
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
|
||||||
const emojiPickerRef = useRef<HTMLDivElement|null>(null);
|
|
||||||
const emojiTriggerRef = useRef<HTMLButtonElement|null>(null);
|
|
||||||
const [emojiPos, setEmojiPos] = useState<{left:number; top:number}>({ left: 0, top: 0 });
|
|
||||||
const EMOJIS = useMemo(()=>{
|
|
||||||
// einfache, breite Auswahl gängiger Emojis; kann später erweitert/extern geladen werden
|
|
||||||
const groups = [
|
|
||||||
'😀😁😂🤣😅😊🙂😉😍😘😜🤪🤗🤔🤩🥳😎😴🤤','😇🥰🥺😡🤬😱😭🙈🙉🙊💀👻🤖🎃','👍👎👏🙌🙏🤝💪🔥✨💥🎉🎊','❤️🧡💛💚💙💜🖤🤍🤎💖💘💝','⭐🌟🌈☀️🌙⚡❄️☔🌊🍀','🎵🎶🎧🎤🎸🥁🎹🎺🎻','🍕🍔🍟🌭🌮🍣🍺🍻🍷🥂','🐶🐱🐼🐸🦄🐧🐢🦖🐙','🚀🛸✈️🚁🚗🏎️🚓🚒','🏆🥇🥈🥉🎯🎮🎲🧩']
|
|
||||||
return groups.join('').split('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function emojiToTwemojiUrl(emoji: string): string {
|
// Chaos Mode (Partymode)
|
||||||
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`;
|
|
||||||
}
|
|
||||||
const [showBroccoli, setShowBroccoli] = useState<boolean>(false);
|
|
||||||
const broccoliItems = useMemo(() => {
|
|
||||||
if (!(theme === '420' && showBroccoli)) return [] as Array<{top:number; left:number; duration:number; delay:number}>;
|
|
||||||
const items: Array<{top:number; left:number; duration:number; delay:number}> = [];
|
|
||||||
for (let i = 0; i < 20; i += 1) {
|
|
||||||
items.push({
|
|
||||||
top: 5 + Math.random() * 90, // 5%..95%
|
|
||||||
left: 2 + Math.random() * 96, // 2%..98%
|
|
||||||
duration: 14 + Math.random() * 14, // 14s..28s
|
|
||||||
delay: Math.random() * 2 // 0..2s
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}, [theme, showBroccoli]);
|
|
||||||
const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]);
|
|
||||||
const selectedCount = useMemo(() => Object.values(selectedSet).filter(Boolean).length, [selectedSet]);
|
|
||||||
const [clock, setClock] = useState<string>(() => new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin' }).format(new Date()));
|
|
||||||
const [totalPlays, setTotalPlays] = useState<number>(0);
|
|
||||||
const [mediaUrl, setMediaUrl] = useState<string>('');
|
|
||||||
const [chaosMode, setChaosMode] = useState<boolean>(false);
|
const [chaosMode, setChaosMode] = useState<boolean>(false);
|
||||||
|
const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]);
|
||||||
const chaosTimeoutRef = useRef<number | null>(null);
|
const chaosTimeoutRef = useRef<number | null>(null);
|
||||||
const chaosModeRef = useRef<boolean>(false);
|
const chaosModeRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
// Scrolled State for Header blur
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
|
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
|
||||||
useEffect(() => { selectedRef.current = selected; }, [selected]);
|
useEffect(() => { selectedRef.current = selected; }, [selected]);
|
||||||
|
|
||||||
|
const showNotification = (msg: string, type: 'info' | 'error' = 'info') => {
|
||||||
|
setNotification({ msg, type });
|
||||||
|
setTimeout(() => setNotification(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Init Load ----------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -88,18 +58,20 @@ export default function App() {
|
||||||
}
|
}
|
||||||
if (initial) setSelected(initial);
|
if (initial) setSelected(initial);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || 'Fehler beim Laden der Channels');
|
showNotification(e?.message || 'Fehler beim Laden der Channels', 'error');
|
||||||
}
|
}
|
||||||
try { setIsAdmin(await adminStatus()); } catch { }
|
try { setIsAdmin(await adminStatus()); } catch { }
|
||||||
try { const cats = await fetchCategories(); setCategories(cats.categories || []); } catch { }
|
try { const cats = await fetchCategories(); setCategories(cats.categories || []); } catch { }
|
||||||
try {
|
|
||||||
const h = await fetch('/api/health').then(r => r.json()).catch(() => null);
|
|
||||||
if (h && typeof h.totalPlays === 'number') setTotalPlays(h.totalPlays);
|
|
||||||
} catch {}
|
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// SSE: Partymode-Status global synchronisieren (sauberes Cleanup)
|
// ---------------- Theme ----------------
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// ---------------- SSE Events ----------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = subscribeEvents((msg) => {
|
const unsub = subscribeEvents((msg) => {
|
||||||
if (msg?.type === 'party') {
|
if (msg?.type === 'party') {
|
||||||
|
|
@ -124,8 +96,7 @@ export default function App() {
|
||||||
const cur = selectedRef.current || '';
|
const cur = selectedRef.current || '';
|
||||||
const gid = cur ? cur.split(':')[0] : '';
|
const gid = cur ? cur.split(':')[0] : '';
|
||||||
if (gid && typeof vols[gid] === 'number') {
|
if (gid && typeof vols[gid] === 'number') {
|
||||||
const v = vols[gid];
|
setVolume(vols[gid]);
|
||||||
setVolume(v);
|
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
} else if (msg?.type === 'channel') {
|
} else if (msg?.type === 'channel') {
|
||||||
|
|
@ -153,89 +124,39 @@ export default function App() {
|
||||||
return () => { try { unsub(); } catch { } };
|
return () => { try { unsub(); } catch { } };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Aus aktivem Guild-Status die lokale Anzeige setzen
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const gid = selected ? selected.split(':')[0] : '';
|
const gid = selected ? selected.split(':')[0] : '';
|
||||||
setChaosMode(gid ? partyActiveGuilds.includes(gid) : false);
|
setChaosMode(gid ? partyActiveGuilds.includes(gid) : false);
|
||||||
}, [selected, partyActiveGuilds]);
|
}, [selected, partyActiveGuilds]);
|
||||||
|
|
||||||
// Uhrzeit (Berlin) aktualisieren
|
// ---------------- Data Fetching ----------------
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder;
|
const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder;
|
||||||
const s = await fetchSounds(query, folderParam, activeCategoryId || undefined, fuzzy);
|
const s = await fetchSounds(query, folderParam, activeCategoryId || undefined, false); // Removed fuzzy param for cleaner UI
|
||||||
setSounds(s.items);
|
setSounds(s.items);
|
||||||
setTotal(s.total);
|
setTotal(s.total);
|
||||||
setFolders(s.folders);
|
setFolders(s.folders);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || 'Fehler beim Laden der Sounds');
|
showNotification(e?.message || 'Fehler beim Laden der Sounds', 'error');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [activeFolder, query, activeCategoryId, fuzzy]);
|
}, [activeFolder, query, activeCategoryId]);
|
||||||
|
|
||||||
// Favoriten aus Cookie laden
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const c = getCookie('favs');
|
const c = getCookie('favs');
|
||||||
if (c) {
|
if (c) { try { setFavs(JSON.parse(c)); } catch { } }
|
||||||
try { setFavs(JSON.parse(c)); } catch {}
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Favoriten persistieren
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try { setCookie('favs', JSON.stringify(favs)); } catch { }
|
try { setCookie('favs', JSON.stringify(favs)); } catch { }
|
||||||
}, [favs]);
|
}, [favs]);
|
||||||
|
|
||||||
// Theme anwenden/persistieren
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.setAttribute('data-theme', theme);
|
|
||||||
if (import.meta.env.VITE_BUILD_CHANNEL === 'nightly') {
|
|
||||||
document.body.setAttribute('data-build', 'nightly');
|
|
||||||
} else {
|
|
||||||
document.body.removeAttribute('data-build');
|
|
||||||
}
|
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
// Back-to-top Sichtbarkeit
|
|
||||||
useEffect(() => {
|
|
||||||
const onScroll = () => setShowTop(window.scrollY > 300);
|
|
||||||
onScroll();
|
|
||||||
window.addEventListener('scroll', onScroll, { passive: true });
|
|
||||||
return () => window.removeEventListener('scroll', onScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
localStorage.setItem('selectedChannel', selected);
|
localStorage.setItem('selectedChannel', selected);
|
||||||
// gespeicherte Lautstärke vom Server laden
|
|
||||||
try {
|
try {
|
||||||
const [guildId] = selected.split(':');
|
const [guildId] = selected.split(':');
|
||||||
const v = await getVolume(guildId);
|
const v = await getVolume(guildId);
|
||||||
|
|
@ -245,36 +166,23 @@ export default function App() {
|
||||||
})();
|
})();
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
|
|
||||||
// Server liefert bereits gefilterte (und ggf. fuzzy-sortierte) Ergebnisse
|
// ---------------- Actions ----------------
|
||||||
const filtered = sounds;
|
|
||||||
|
|
||||||
const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]);
|
|
||||||
|
|
||||||
function toggleSelect(key: string, on?: boolean) {
|
|
||||||
setSelectedSet((prev) => ({ ...prev, [key]: typeof on === 'boolean' ? on : !prev[key] }));
|
|
||||||
}
|
|
||||||
function clearSelection() {
|
|
||||||
setSelectedSet({});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePlay(name: string, rel?: string) {
|
async function handlePlay(name: string, rel?: string) {
|
||||||
setError(null);
|
if (!selected) return showNotification('Bitte einen Voice-Channel auswählen', 'error');
|
||||||
if (!selected) return setError('Bitte einen Voice-Channel auswählen');
|
|
||||||
const [guildId, channelId] = selected.split(':');
|
const [guildId, channelId] = selected.split(':');
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await playSound(name, guildId, channelId, volume, rel);
|
await playSound(name, guildId, channelId, volume, rel);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || 'Play fehlgeschlagen');
|
showNotification(e?.message || 'Wiedergabe fehlgeschlagen', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CHAOS Mode Funktionen (zufällige Wiedergabe alle 1-3 Minuten)
|
// Chaos Mode Logic
|
||||||
const startChaosMode = async () => {
|
const startChaosMode = async () => {
|
||||||
if (!selected || !sounds.length) return;
|
if (!selected || !sounds.length) return;
|
||||||
|
|
||||||
const playRandomSound = async () => {
|
const playRandomSound = async () => {
|
||||||
const pool = sounds;
|
const pool = sounds;
|
||||||
if (!pool.length || !selected) return;
|
if (!pool.length || !selected) return;
|
||||||
|
|
@ -282,9 +190,7 @@ export default function App() {
|
||||||
const [guildId, channelId] = selected.split(':');
|
const [guildId, channelId] = selected.split(':');
|
||||||
try {
|
try {
|
||||||
await playSound(randomSound.name, guildId, channelId, volume, randomSound.relativePath);
|
await playSound(randomSound.name, guildId, channelId, volume, randomSound.relativePath);
|
||||||
} catch (e: any) {
|
} catch (e: any) { console.error('Chaos sound play failed:', e); }
|
||||||
console.error('Chaos sound play failed:', e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleNextPlay = async () => {
|
const scheduleNextPlay = async () => {
|
||||||
|
|
@ -294,9 +200,7 @@ export default function App() {
|
||||||
chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, delay);
|
chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, delay);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sofort ersten Sound abspielen
|
|
||||||
await playRandomSound();
|
await playRandomSound();
|
||||||
// Nächsten zufällig in 1-3 Minuten planen
|
|
||||||
const firstDelay = 30_000 + Math.floor(Math.random() * 60_000);
|
const firstDelay = 30_000 + Math.floor(Math.random() * 60_000);
|
||||||
chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, firstDelay);
|
chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, firstDelay);
|
||||||
};
|
};
|
||||||
|
|
@ -306,15 +210,9 @@ export default function App() {
|
||||||
clearTimeout(chaosTimeoutRef.current);
|
clearTimeout(chaosTimeoutRef.current);
|
||||||
chaosTimeoutRef.current = null;
|
chaosTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle Sounds stoppen (wie Panic Button)
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const [guildId] = selected.split(':');
|
const [guildId] = selected.split(':');
|
||||||
try {
|
try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { }
|
||||||
await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' });
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Chaos stop failed:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -322,502 +220,234 @@ export default function App() {
|
||||||
if (chaosMode) {
|
if (chaosMode) {
|
||||||
setChaosMode(false);
|
setChaosMode(false);
|
||||||
await stopChaosMode();
|
await stopChaosMode();
|
||||||
// serverseitig stoppen
|
|
||||||
if (selected) { const [guildId] = selected.split(':'); try { await partyStop(guildId); } catch { } }
|
if (selected) { const [guildId] = selected.split(':'); try { await partyStop(guildId); } catch { } }
|
||||||
} else {
|
} else {
|
||||||
setChaosMode(true);
|
setChaosMode(true);
|
||||||
await startChaosMode();
|
await startChaosMode();
|
||||||
// serverseitig starten
|
|
||||||
if (selected) { const [guildId, channelId] = selected.split(':'); try { await partyStart(guildId, channelId); } catch { } }
|
if (selected) { const [guildId, channelId] = selected.split(':'); try { await partyStart(guildId, channelId); } catch { } }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cleanup bei Komponenten-Unmount
|
// Filter Data
|
||||||
useEffect(() => {
|
const filtered = sounds;
|
||||||
return () => {
|
const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]);
|
||||||
if (chaosTimeoutRef.current) {
|
|
||||||
clearTimeout(chaosTimeoutRef.current);
|
// Scroll Handler for Top Bar Blur
|
||||||
}
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
setIsScrolled(e.currentTarget.scrollTop > 20);
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<div className="app-layout">
|
||||||
<div className="page-container" data-theme={theme}>
|
{/* ---------------- Sidebar ---------------- */}
|
||||||
{/* Floating Broccoli for 420 Theme */}
|
<aside className="sidebar">
|
||||||
{theme === '420' && showBroccoli && (
|
<h1 className="title-large" style={{ marginLeft: 12, marginBottom: 32, fontSize: 28, background: 'linear-gradient(45deg, var(--accent-blue), #5e5ce6)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
||||||
<>
|
Jukebox
|
||||||
{broccoliItems.map((b, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="broccoli"
|
|
||||||
style={{ top: `${b.top}%`, left: `${b.left}%`, animationDuration: `${b.duration}s`, animationDelay: `${b.delay}s` }}
|
|
||||||
>
|
|
||||||
🥦
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<header className="flex items-center justify-between p-6">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold site-title">
|
|
||||||
Jukebox 420
|
|
||||||
{import.meta.env.VITE_BUILD_CHANNEL === 'nightly' && (
|
|
||||||
<div className="text-sm font-normal mt-1 opacity-70">
|
|
||||||
<span className="ml-2" style={{ color: '#ff4d4f' }}>• Nightly</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-7xl font-bold mt-2">{clock}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-lg text-gray-400">Sounds</p>
|
|
||||||
<p className="text-2xl font-bold">{total}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-lg text-gray-400">Played</p>
|
|
||||||
<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>
|
|
||||||
<button
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
Partymode
|
|
||||||
</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 () => { setChaosMode(false); await stopChaosMode(); if(selected){ const [guildId]=selected.split(':'); try{ await partyStop(guildId);}catch{} } }}>Panic</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="control-panel rounded-xl p-6 mb-8">
|
<div className="sidebar-title">Library</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-center">
|
<button className={`nav-item ${activeFolder === '__all__' ? 'active' : ''}`} onClick={() => setActiveFolder('__all__')}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>library_music</span>All Sounds</div>
|
||||||
<button
|
<span style={{ opacity: 0.5, fontSize: 12 }}>{total}</span>
|
||||||
className={`font-bold p-3 rounded-lg transition duration-300 ${fuzzy ? 'bg-green-600 hover:bg-green-500 text-white' : 'bg-gray-700 hover:bg-gray-600 text-white'}`}
|
|
||||||
onClick={() => setFuzzy((v) => !v)}
|
|
||||||
title="Fuzzy-Suche umschalten"
|
|
||||||
aria-label="Fuzzy-Suche umschalten"
|
|
||||||
aria-pressed={fuzzy}
|
|
||||||
>
|
|
||||||
<span className="material-icons" aria-hidden="true">blur_on</span>
|
|
||||||
</button>
|
</button>
|
||||||
<div className="relative flex-1">
|
<button className={`nav-item ${activeFolder === '__favs__' ? 'active' : ''}`} onClick={() => setActiveFolder('__favs__')}>
|
||||||
<input className="input-field pl-10 with-left-icon w-full" placeholder="Nach Sounds suchen..." value={query} onChange={(e)=>setQuery(e.target.value)} />
|
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>favorite</span>Favorites</div>
|
||||||
<span className="material-icons absolute left-3 top-1/2 -translate-y-1/2" style={{color:'var(--text-secondary)'}}>search</span>
|
<span style={{ opacity: 0.5, fontSize: 12 }}>{favCount}</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<CustomSelect channels={channels} value={selected} onChange={async (v)=>{
|
|
||||||
setSelected(v);
|
|
||||||
try {
|
|
||||||
const [gid, cid] = v.split(':');
|
|
||||||
await setSelectedChannel(gid, cid);
|
|
||||||
} catch (e) { /* noop */ }
|
|
||||||
}} />
|
|
||||||
<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>
|
|
||||||
<input
|
|
||||||
className="volume-slider w-full h-2 rounded-lg appearance-none cursor-pointer"
|
|
||||||
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)}%` }}
|
|
||||||
/>
|
|
||||||
<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">
|
|
||||||
<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'); } } }} />
|
|
||||||
<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>
|
</button>
|
||||||
</div>
|
<button className={`nav-item ${activeFolder === '__recent__' ? 'active' : ''}`} onClick={() => setActiveFolder('__recent__')}>
|
||||||
<div className="flex items-center space-x-3 lg:col-span-2">
|
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>schedule</span>Recently Added</div>
|
||||||
<div className="relative flex-grow">
|
</button>
|
||||||
<select title="Theme Auswahl" className="input-field appearance-none pl-10" value={theme} onChange={(e)=>setTheme(e.target.value)}>
|
|
||||||
<option value="dark">Theme: Dark</option>
|
{folders.length > 3 && ( // 3 = __all__, __recent__, __top3__
|
||||||
<option value="rainbow">Theme: Rainbow</option>
|
|
||||||
<option value="420">Theme: 420</option>
|
|
||||||
</select>
|
|
||||||
<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>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6" style={{borderTop:'1px solid var(--border-color)', paddingTop:'1.5rem'}}>
|
|
||||||
<div className="flex items-center gap-4 justify-between flex-wrap">
|
|
||||||
{!isAdmin ? (
|
|
||||||
<>
|
<>
|
||||||
<div className="relative w-full sm:w-auto" style={{maxWidth:'15%'}}>
|
<div className="sidebar-title" style={{ marginTop: 32 }}>Folders</div>
|
||||||
<input
|
{folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)).map(f => {
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-3 w-full">
|
|
||||||
<span className="bg-gray-700 text-white font-bold py-3 px-6 rounded-lg">Ausgewählt: {selectedCount}</span>
|
|
||||||
{selectedCount > 0 && (
|
|
||||||
<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 toDelete = Object.entries(selectedSet).filter(([,v])=>v).map(([k])=>k);
|
|
||||||
await adminDelete(toDelete);
|
|
||||||
clearSelection();
|
|
||||||
const resp = await fetchSounds(query, activeFolder === '__favs__' ? '__all__' : activeFolder, activeCategoryId || undefined, fuzzy);
|
|
||||||
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, activeCategoryId || undefined, fuzzy);
|
|
||||||
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
|
|
||||||
} catch (e:any) { setError(e?.message||'Umbenennen fehlgeschlagen'); }
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
{/* 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, fuzzy);
|
|
||||||
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
|
|
||||||
}catch(e:any){ setError(e?.message||'Zuweisung fehlgeschlagen'); setInfo(null); }
|
|
||||||
}}
|
|
||||||
>Zu Kategorie</button>
|
|
||||||
|
|
||||||
{/* Custom Emoji Feature entfernt */}
|
|
||||||
|
|
||||||
<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, fuzzy);
|
|
||||||
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
|
|
||||||
}catch(err:any){ setError(err?.message||'Badge-Entfernung fehlgeschlagen'); setInfo(null); }
|
|
||||||
}}
|
|
||||||
>Badges entfernen</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
{/* Kategorien: anlegen/umbenennen/löschen */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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>}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
{folders.map(f=> {
|
|
||||||
const displayName = f.name.replace(/\s*\(\d+\)\s*$/, '');
|
const displayName = f.name.replace(/\s*\(\d+\)\s*$/, '');
|
||||||
return (
|
return (
|
||||||
<button
|
<button key={f.key} className={`nav-item ${activeFolder === f.key ? 'active' : ''}`} onClick={() => setActiveFolder(f.key)}>
|
||||||
key={f.key}
|
<div className="flex items-center gap-2" style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}><span className="material-icons" style={{ fontSize: 20 }}>folder</span>{displayName}</div>
|
||||||
className={`tag-btn ${activeFolder===f.key?'active':''}`}
|
<span style={{ opacity: 0.5, fontSize: 12 }}>{f.count}</span>
|
||||||
onClick={async ()=>{
|
|
||||||
setActiveFolder(f.key);
|
|
||||||
const resp=await fetchSounds(undefined, f.key, activeCategoryId || undefined);
|
|
||||||
setSounds(resp.items); setTotal(resp.total); setFolders(resp.folders);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayName} ({f.count})
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-3 text-sm mt-3">
|
<>
|
||||||
|
<div className="sidebar-title" style={{ marginTop: 32 }}>Categories</div>
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
<button
|
<button key={cat.id} className={`nav-item ${activeCategoryId === cat.id ? 'active' : ''}`} onClick={() => setActiveCategoryId(prev => prev === cat.id ? '' : cat.id)}>
|
||||||
key={cat.id}
|
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>label</span>{cat.name}</div>
|
||||||
className={`tag-btn ${activeCategoryId===cat.id?'active':''}`}
|
|
||||||
onClick={()=> setActiveCategoryId(prev => (prev === cat.id ? '' : cat.id))}
|
|
||||||
>
|
|
||||||
{cat.name}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ---------------- Main Content ---------------- */}
|
||||||
|
<main className="main-content" onScroll={handleScroll}>
|
||||||
|
<header className={`top-bar ${isScrolled ? 'scrolled' : ''}`}>
|
||||||
|
<h2 className="title-large" style={{ marginBottom: 0 }}>
|
||||||
|
{activeFolder === '__all__' ? 'All Sounds' :
|
||||||
|
activeFolder === '__favs__' ? 'Favorites' :
|
||||||
|
activeFolder === '__recent__' ? 'Recently Added' :
|
||||||
|
folders.find(f => f.key === activeFolder)?.name.replace(/\s*\(\d+\)\s*$/, '') || 'Library'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
|
<div className="search-box">
|
||||||
|
<span className="material-icons search-icon">search</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-modern"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className="sounds-flow">
|
<select
|
||||||
|
className="select-modern"
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => setTheme(e.target.value)}
|
||||||
|
style={{ paddingRight: 20, paddingLeft: 12 }}
|
||||||
|
>
|
||||||
|
<option value="dark">Dark Theme</option>
|
||||||
|
<option value="light">Light Theme</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="track-container">
|
||||||
|
<div className="track-grid">
|
||||||
{(activeFolder === '__favs__' ? filtered.filter((s) => !!favs[s.relativePath ?? s.fileName]) : filtered).map((s) => {
|
{(activeFolder === '__favs__' ? filtered.filter((s) => !!favs[s.relativePath ?? s.fileName]) : filtered).map((s) => {
|
||||||
const key = `${s.relativePath ?? s.fileName}`;
|
const key = `${s.relativePath ?? s.fileName}`;
|
||||||
const isFav = !!favs[key];
|
const isFav = !!favs[key];
|
||||||
return (
|
return (
|
||||||
<div key={`${s.fileName}-${s.name}`} className="sound-wrap">
|
<div key={key} className="track-card" onClick={() => handlePlay(s.name, s.relativePath)}>
|
||||||
{isAdmin && (
|
<button
|
||||||
<input
|
className={`fav-btn ${isFav ? 'active' : ''}`}
|
||||||
type="checkbox"
|
onClick={(e) => { e.stopPropagation(); setFavs(prev => ({ ...prev, [key]: !prev[key] })); }}
|
||||||
className="select-check"
|
>
|
||||||
checked={!!selectedSet[key]}
|
<span className="material-icons">{isFav ? 'star' : 'star_border'}</span>
|
||||||
onChange={(e)=>{ e.stopPropagation(); toggleSelect(key, e.target.checked); }}
|
</button>
|
||||||
/>
|
<div className="track-icon">
|
||||||
|
<span className="material-icons" style={{ fontSize: 32 }}>music_note</span>
|
||||||
|
</div>
|
||||||
|
<div className="track-name">{s.name}</div>
|
||||||
|
|
||||||
|
{Array.isArray((s as any).badges) && (s as any).badges!.includes('new') && (
|
||||||
|
<div className="badge-new">NEW</div>
|
||||||
)}
|
)}
|
||||||
<div className="sound-btn group rounded-xl flex items-center justify-between p-3 cursor-pointer"
|
|
||||||
onClick={()=>handlePlay(s.name, s.relativePath)}>
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{/* Footer intentionally left without version display */}
|
|
||||||
|
{/* ---------------- Bottom Control Bar ---------------- */}
|
||||||
|
<div className="bottom-player">
|
||||||
|
<div className="player-section">
|
||||||
|
{/* Target Channel */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="material-icons" style={{ color: 'var(--text-secondary)' }}>headset_mic</span>
|
||||||
|
<select
|
||||||
|
className="select-modern"
|
||||||
|
value={selected}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setSelected(v);
|
||||||
|
try {
|
||||||
|
const [gid, cid] = v.split(':');
|
||||||
|
await setSelectedChannel(gid, cid);
|
||||||
|
} catch { }
|
||||||
|
}}
|
||||||
|
style={{ width: '240px', background: 'transparent', border: '1px solid var(--border-color)' }}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Select Channel...</option>
|
||||||
|
{channels.map((c) => (
|
||||||
|
<option key={`${c.guildId}:${c.channelId}`} value={`${c.guildId}:${c.channelId}`}>
|
||||||
|
{c.guildName} • {c.channelName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showTop && (
|
|
||||||
<button type="button" className="back-to-top" aria-label="Nach oben" onClick={()=>window.scrollTo({top:0, behavior:'smooth'})}>↑ Top</button>
|
|
||||||
)}
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelectProps = {
|
<div className="player-section center">
|
||||||
channels: VoiceChannelInfo[];
|
{/* Playback Controls */}
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function CustomSelect({ channels, value, onChange }: SelectProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
||||||
const [menuPos, setMenuPos] = useState<{ left: number; top: number; width: number }>({ left: 0, top: 0, width: 0 });
|
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const current = channels.find(c => `${c.guildId}:${c.channelId}` === value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="control select custom-select" ref={ref}>
|
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
className="btn-danger"
|
||||||
type="button"
|
onClick={async () => {
|
||||||
className="select-trigger"
|
setChaosMode(false); await stopChaosMode();
|
||||||
onClick={() => setOpen(v => !v)}
|
if (selected) { const [guildId] = selected.split(':'); try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } }
|
||||||
title={current ? `Channel: ${current.guildName} – ${current.channelName}` : 'Channel wählen'}
|
}}
|
||||||
|
title="Stop All"
|
||||||
>
|
>
|
||||||
{current ? `Channel: ${current.guildName} – ${current.channelName}` : 'Channel wählen'}
|
<span className="material-icons" style={{ fontSize: 20 }}>stop</span>
|
||||||
<span className="chev">▾</span>
|
|
||||||
</button>
|
</button>
|
||||||
{open && typeof document !== 'undefined' && ReactDOM.createPortal(
|
|
||||||
<div
|
|
||||||
className="select-menu"
|
|
||||||
style={{ position: 'fixed', left: menuPos.left, top: menuPos.top, width: menuPos.width, zIndex: 30000 }}
|
|
||||||
>
|
|
||||||
{channels.map((c) => {
|
|
||||||
const v = `${c.guildId}:${c.channelId}`;
|
|
||||||
const active = v === value;
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
className="btn-primary"
|
||||||
key={v}
|
onClick={async () => {
|
||||||
className={`select-item ${active ? 'active' : ''}`}
|
try {
|
||||||
onClick={() => { onChange(v); setOpen(false); }}
|
const items = sounds;
|
||||||
|
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 { }
|
||||||
|
}}
|
||||||
|
title="Play Random Sound"
|
||||||
|
style={{ padding: '12px 24px', display: 'flex', alignItems: 'center', gap: 6 }}
|
||||||
>
|
>
|
||||||
{c.guildName} – {c.channelName}
|
<span className="material-icons" style={{ fontSize: 20 }}>shuffle</span> Shuffle
|
||||||
</button>
|
</button>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Einfache ErrorBoundary, damit die Seite nicht blank wird und Fehler sichtbar sind
|
<button
|
||||||
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { error?: Error }>{
|
className={`btn-icon ${chaosMode ? 'btn-chaos' : ''}`}
|
||||||
constructor(props: { children: React.ReactNode }) {
|
onClick={toggleChaosMode}
|
||||||
super(props);
|
title="Partymode (Auto-Play)"
|
||||||
this.state = { error: undefined };
|
style={chaosMode ? { width: 'auto', padding: '0 16px', borderRadius: 999, display: 'flex', gap: 6 } : {}}
|
||||||
}
|
>
|
||||||
static getDerivedStateFromError(error: Error) { return { error }; }
|
<span className="material-icons" style={{ fontSize: 20 }}>{chaosMode ? 'celebration' : 'all_inclusive'}</span>
|
||||||
componentDidCatch(error: Error, info: any) { console.error('UI-ErrorBoundary:', error, info); }
|
{chaosMode && <span>Party Active</span>}
|
||||||
render() {
|
</button>
|
||||||
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>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.props.children as any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline-Komponente für Umbenennen (nur bei genau 1 Selektion sichtbar)
|
<div className="player-section right">
|
||||||
type RenameInlineProps = { onSubmit: (newName: string) => void | Promise<void> };
|
{/* Volume */}
|
||||||
function RenameInline({ onSubmit }: RenameInlineProps) {
|
<div className="volume-container">
|
||||||
const [val, setVal] = useState('');
|
<span className="material-icons" style={{ color: 'var(--text-secondary)', fontSize: 18 }}>volume_down</span>
|
||||||
async function submit() {
|
|
||||||
const n = val.trim();
|
|
||||||
if (!n) return;
|
|
||||||
await onSubmit(n);
|
|
||||||
setVal('');
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<input
|
<input
|
||||||
value={val}
|
className="volume-slider"
|
||||||
onChange={(e) => setVal(e.target.value)}
|
type="range"
|
||||||
placeholder="Neuer Name"
|
min={0} max={1} step={0.01}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') void submit(); }}
|
value={volume}
|
||||||
style={{ color: '#000000' }}
|
onChange={async (e) => {
|
||||||
|
const v = parseFloat(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
try { (e.target as HTMLInputElement).style.setProperty('--_fill', `${Math.round(v * 100)}%`); } catch { }
|
||||||
|
if (selected) { const [guildId] = selected.split(':'); try { await setVolumeLive(guildId, v); } catch { } }
|
||||||
|
}}
|
||||||
|
style={{ ['--_fill' as any]: `${Math.round(volume * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
<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>
|
<span className="material-icons" style={{ color: 'var(--text-secondary)', fontSize: 18 }}>volume_up</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notification && (
|
||||||
|
<div className={`notification ${notification.type}`}>
|
||||||
|
<span className="material-icons" style={{ fontSize: 18 }}>
|
||||||
|
{notification.type === 'error' ? 'error_outline' : 'check_circle'}
|
||||||
|
</span>
|
||||||
|
{notification.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
1235
web/src/styles.css
1235
web/src/styles.css
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue