- Neues unified Login-Modal (Discord, Steam, Admin) ersetzt alten Admin-Login
- Discord OAuth2 Backend (server/src/core/discord-auth.ts)
- User Settings Panel: Entrance/Exit Sounds per Web-GUI konfigurierbar
- API-Endpoints: /api/soundboard/user/{sounds,entrance,exit}
- Session-Management via HMAC-signierte Cookies (hub_session)
- Steam-Button als Platzhalter (bald verfuegbar)
- Backward-kompatibel mit bestehendem Admin-Cookie
Benoetigte neue Env-Vars: DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
Discord Redirect URI: PUBLIC_URL/api/auth/discord/callback
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
254 lines
9.2 KiB
TypeScript
254 lines
9.2 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
|
|
interface UserInfo {
|
|
discordId: string;
|
|
username: string;
|
|
avatar: string | null;
|
|
globalName: string | null;
|
|
}
|
|
|
|
interface SoundOption {
|
|
name: string;
|
|
fileName: string;
|
|
folder: string;
|
|
relativePath: string;
|
|
}
|
|
|
|
interface UserSettingsProps {
|
|
user: UserInfo;
|
|
onClose: () => void;
|
|
onLogout: () => void;
|
|
}
|
|
|
|
export default function UserSettings({ user, onClose, onLogout }: UserSettingsProps) {
|
|
const [entranceSound, setEntranceSound] = useState<string | null>(null);
|
|
const [exitSound, setExitSound] = useState<string | null>(null);
|
|
const [availableSounds, setAvailableSounds] = useState<SoundOption[]>([]);
|
|
const [search, setSearch] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState<'entrance' | 'exit' | null>(null);
|
|
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
|
const [activeSection, setActiveSection] = useState<'entrance' | 'exit'>('entrance');
|
|
|
|
// Fetch current sounds + available sounds
|
|
useEffect(() => {
|
|
Promise.all([
|
|
fetch('/api/soundboard/user/sounds', { credentials: 'include' }).then(r => r.json()),
|
|
fetch('/api/soundboard/user/available-sounds').then(r => r.json()),
|
|
])
|
|
.then(([userSounds, sounds]) => {
|
|
setEntranceSound(userSounds.entrance ?? null);
|
|
setExitSound(userSounds.exit ?? null);
|
|
setAvailableSounds(sounds);
|
|
setLoading(false);
|
|
})
|
|
.catch(() => {
|
|
setMessage({ text: 'Fehler beim Laden der Einstellungen', type: 'error' });
|
|
setLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
// Close on Escape
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, [onClose]);
|
|
|
|
const showMessage = useCallback((text: string, type: 'success' | 'error') => {
|
|
setMessage({ text, type });
|
|
setTimeout(() => setMessage(null), 3000);
|
|
}, []);
|
|
|
|
async function setSound(type: 'entrance' | 'exit', fileName: string) {
|
|
setSaving(type);
|
|
try {
|
|
const resp = await fetch(`/api/soundboard/user/${type}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName }),
|
|
credentials: 'include',
|
|
});
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
if (type === 'entrance') setEntranceSound(data.entrance);
|
|
else setExitSound(data.exit);
|
|
showMessage(`${type === 'entrance' ? 'Entrance' : 'Exit'}-Sound gesetzt!`, 'success');
|
|
} else {
|
|
const err = await resp.json().catch(() => ({ error: 'Unbekannter Fehler' }));
|
|
showMessage(err.error || 'Fehler', 'error');
|
|
}
|
|
} catch {
|
|
showMessage('Verbindungsfehler', 'error');
|
|
}
|
|
setSaving(null);
|
|
}
|
|
|
|
async function removeSound(type: 'entrance' | 'exit') {
|
|
setSaving(type);
|
|
try {
|
|
const resp = await fetch(`/api/soundboard/user/${type}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
});
|
|
if (resp.ok) {
|
|
if (type === 'entrance') setEntranceSound(null);
|
|
else setExitSound(null);
|
|
showMessage(`${type === 'entrance' ? 'Entrance' : 'Exit'}-Sound entfernt`, 'success');
|
|
}
|
|
} catch {
|
|
showMessage('Verbindungsfehler', 'error');
|
|
}
|
|
setSaving(null);
|
|
}
|
|
|
|
// Group sounds by folder
|
|
const folders = new Map<string, SoundOption[]>();
|
|
const q = search.toLowerCase();
|
|
for (const s of availableSounds) {
|
|
if (q && !s.name.toLowerCase().includes(q) && !s.fileName.toLowerCase().includes(q)) continue;
|
|
const key = s.folder || 'Allgemein';
|
|
if (!folders.has(key)) folders.set(key, []);
|
|
folders.get(key)!.push(s);
|
|
}
|
|
// Sort folders alphabetically, "Allgemein" first
|
|
const sortedFolders = [...folders.entries()].sort(([a], [b]) => {
|
|
if (a === 'Allgemein') return -1;
|
|
if (b === 'Allgemein') return 1;
|
|
return a.localeCompare(b);
|
|
});
|
|
|
|
const currentSound = activeSection === 'entrance' ? entranceSound : exitSound;
|
|
|
|
return (
|
|
<div className="hub-usettings-overlay" onClick={onClose}>
|
|
<div className="hub-usettings-panel" onClick={e => e.stopPropagation()}>
|
|
{/* Header */}
|
|
<div className="hub-usettings-header">
|
|
<div className="hub-usettings-user">
|
|
{user.avatar ? (
|
|
<img src={user.avatar} alt="" className="hub-usettings-avatar" />
|
|
) : (
|
|
<div className="hub-usettings-avatar-placeholder">{user.username[0]?.toUpperCase()}</div>
|
|
)}
|
|
<div className="hub-usettings-user-info">
|
|
<span className="hub-usettings-username">{user.globalName || user.username}</span>
|
|
<span className="hub-usettings-discriminator">@{user.username}</span>
|
|
</div>
|
|
</div>
|
|
<div className="hub-usettings-header-actions">
|
|
<button className="hub-usettings-logout" onClick={onLogout} title="Abmelden">
|
|
{'\uD83D\uDEAA'}
|
|
</button>
|
|
<button className="hub-usettings-close" onClick={onClose}>
|
|
{'\u2715'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Message toast */}
|
|
{message && (
|
|
<div className={`hub-usettings-toast ${message.type}`}>
|
|
{message.type === 'success' ? '\u2705' : '\u274C'} {message.text}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="hub-usettings-loading">
|
|
<span className="hub-update-spinner" /> Lade Einstellungen...
|
|
</div>
|
|
) : (
|
|
<div className="hub-usettings-content">
|
|
{/* Section tabs */}
|
|
<div className="hub-usettings-tabs">
|
|
<button
|
|
className={`hub-usettings-tab ${activeSection === 'entrance' ? 'active' : ''}`}
|
|
onClick={() => setActiveSection('entrance')}
|
|
>
|
|
{'\uD83D\uDC4B'} Entrance-Sound
|
|
</button>
|
|
<button
|
|
className={`hub-usettings-tab ${activeSection === 'exit' ? 'active' : ''}`}
|
|
onClick={() => setActiveSection('exit')}
|
|
>
|
|
{'\uD83D\uDC4E'} Exit-Sound
|
|
</button>
|
|
</div>
|
|
|
|
{/* Current sound display */}
|
|
<div className="hub-usettings-current">
|
|
<span className="hub-usettings-current-label">
|
|
Aktuell: {' '}
|
|
</span>
|
|
{currentSound ? (
|
|
<span className="hub-usettings-current-value">
|
|
{'\uD83C\uDFB5'} {currentSound}
|
|
<button
|
|
className="hub-usettings-remove-btn"
|
|
onClick={() => removeSound(activeSection)}
|
|
disabled={saving === activeSection}
|
|
title="Entfernen"
|
|
>
|
|
{'\u2715'}
|
|
</button>
|
|
</span>
|
|
) : (
|
|
<span className="hub-usettings-current-none">Kein Sound gesetzt</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="hub-usettings-search-wrap">
|
|
<input
|
|
type="text"
|
|
className="hub-usettings-search"
|
|
placeholder="Sounds durchsuchen..."
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
/>
|
|
{search && (
|
|
<button className="hub-usettings-search-clear" onClick={() => setSearch('')}>
|
|
{'\u2715'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sound list */}
|
|
<div className="hub-usettings-sounds">
|
|
{sortedFolders.length === 0 ? (
|
|
<div className="hub-usettings-empty">
|
|
{search ? 'Keine Treffer' : 'Keine Sounds verfügbar'}
|
|
</div>
|
|
) : (
|
|
sortedFolders.map(([folder, sounds]) => (
|
|
<div key={folder} className="hub-usettings-folder">
|
|
<div className="hub-usettings-folder-name">{'\uD83D\uDCC1'} {folder}</div>
|
|
<div className="hub-usettings-folder-sounds">
|
|
{sounds.map(s => {
|
|
const isSelected = currentSound === s.relativePath || currentSound === s.fileName;
|
|
return (
|
|
<button
|
|
key={s.relativePath}
|
|
className={`hub-usettings-sound-btn ${isSelected ? 'selected' : ''}`}
|
|
onClick={() => setSound(activeSection, s.fileName)}
|
|
disabled={saving === activeSection}
|
|
title={s.relativePath}
|
|
>
|
|
<span className="hub-usettings-sound-icon">
|
|
{isSelected ? '\u2705' : '\uD83C\uDFB5'}
|
|
</span>
|
|
<span className="hub-usettings-sound-name">{s.name}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|