diff --git a/server/src/index.ts b/server/src/index.ts index 240620c..6c322c2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,6 +14,7 @@ import lolstatsPlugin from './plugins/lolstats/index.js'; import streamingPlugin, { attachWebSocket } from './plugins/streaming/index.js'; import watchTogetherPlugin, { attachWatchTogetherWs } from './plugins/watch-together/index.js'; import gameLibraryPlugin from './plugins/game-library/index.js'; +import notificationsPlugin from './plugins/notifications/index.js'; // ── Config ── const PORT = Number(process.env.PORT ?? 8080); @@ -25,6 +26,7 @@ const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '') // Per-bot tokens (DISCORD_TOKEN is legacy fallback for jukebox/soundboard) const TOKEN_JUKEBOX = process.env.DISCORD_TOKEN_JUKEBOX ?? process.env.DISCORD_TOKEN ?? ''; const TOKEN_RADIO = process.env.DISCORD_TOKEN_RADIO ?? ''; +const TOKEN_NOTIFICATIONS = process.env.DISCORD_TOKEN_NOTIFICATIONS ?? ''; // ── Persistence ── loadState(); @@ -43,6 +45,9 @@ if (TOKEN_JUKEBOX) clients.push({ name: 'Jukebox', client: clientJukebox, token: const clientRadio = createClient(); if (TOKEN_RADIO) clients.push({ name: 'Radio', client: clientRadio, token: TOKEN_RADIO }); +const clientNotifications = createClient(); +if (TOKEN_NOTIFICATIONS) clients.push({ name: 'Notifications', client: clientNotifications, token: TOKEN_NOTIFICATIONS }); + // ── Plugin Contexts ── const ctxJukebox: PluginContext = { client: clientJukebox, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; const ctxRadio: PluginContext = { client: clientRadio, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; @@ -149,6 +154,10 @@ async function boot(): Promise { const ctxGameLibrary: PluginContext = { client: clientGameLibrary, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; registerPlugin(gameLibraryPlugin, ctxGameLibrary); + // notifications bot — uses its own Discord token for sending messages + const ctxNotifications: PluginContext = { client: clientNotifications, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; + registerPlugin(notificationsPlugin, ctxNotifications); + // Init all plugins for (const p of getPlugins()) { const pCtx = getPluginCtx(p.name)!; diff --git a/server/src/plugins/notifications/index.ts b/server/src/plugins/notifications/index.ts new file mode 100644 index 0000000..c78db4f --- /dev/null +++ b/server/src/plugins/notifications/index.ts @@ -0,0 +1,261 @@ +import type express from 'express'; +import crypto from 'node:crypto'; +import { Client, EmbedBuilder, TextChannel, ChannelType } from 'discord.js'; +import type { Plugin, PluginContext } from '../../core/plugin.js'; +import { getState, setState } from '../../core/persistence.js'; + +const NB = '[Notifications]'; + +// ── Types ── + +interface NotifyChannelConfig { + channelId: string; + channelName: string; + guildId: string; + guildName: string; + events: string[]; // e.g. ['stream_start', 'stream_end'] +} + +interface NotificationConfig { + channels: NotifyChannelConfig[]; +} + +// ── Module-level state ── + +let _client: Client | null = null; +let _ctx: PluginContext | null = null; +let _publicUrl = ''; + +// ── Admin Auth (JWT-like with HMAC) ── + +type AdminPayload = { iat: number; exp: number }; + +function readCookie(req: express.Request, name: string): string | undefined { + const header = req.headers.cookie; + if (!header) return undefined; + const match = header.split(';').map(s => s.trim()).find(s => s.startsWith(`${name}=`)); + return match?.split('=').slice(1).join('='); +} + +function b64url(str: string): string { + return Buffer.from(str).toString('base64url'); +} + +function verifyAdminToken(adminPwd: string, token: string | undefined): boolean { + if (!adminPwd || !token) return false; + const parts = token.split('.'); + if (parts.length !== 2) return false; + const [body, sig] = parts; + const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); + if (expected !== sig) return false; + try { + const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as AdminPayload; + return typeof payload.exp === 'number' && Date.now() < payload.exp; + } catch { return false; } +} + +function signAdminToken(adminPwd: string): string { + const payload: AdminPayload = { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600_000 }; + const body = b64url(JSON.stringify(payload)); + const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); + return `${body}.${sig}`; +} + +// ── Exported notification functions (called by other plugins) ── + +export async function notifyStreamStart(info: { + streamId: string; + broadcasterName: string; + title: string; + hasPassword: boolean; +}): Promise { + if (!_client?.isReady()) return; + const config = getState('notifications_config', { channels: [] }); + const targets = config.channels.filter(c => c.events.includes('stream_start')); + if (targets.length === 0) return; + + const streamUrl = _publicUrl ? `${_publicUrl}?viewStream=${info.streamId}` : null; + + const embed = new EmbedBuilder() + .setColor(0x57F287) // green + .setTitle('🔴 Stream gestartet') + .addFields( + { name: 'Titel', value: info.title, inline: true }, + { name: 'Streamer', value: info.broadcasterName, inline: true }, + ) + .setTimestamp(); + + if (info.hasPassword) { + embed.addFields({ name: '🔒', value: 'Passwortgeschützt', inline: true }); + } + if (streamUrl) { + embed.addFields({ name: 'Link', value: `[Stream öffnen](${streamUrl})` }); + } + + for (const target of targets) { + try { + const channel = await _client.channels.fetch(target.channelId); + if (channel?.type === ChannelType.GuildText) { + await (channel as TextChannel).send({ embeds: [embed] }); + } + } catch (err) { + console.error(`${NB} Failed to send to ${target.channelId}:`, err); + } + } + console.log(`${NB} Stream-start notification sent to ${targets.length} channel(s)`); +} + +export async function notifyStreamEnd(info: { + broadcasterName: string; + title: string; + viewerCount: number; + duration: string; // human readable e.g. "1h 23m" +}): Promise { + if (!_client?.isReady()) return; + const config = getState('notifications_config', { channels: [] }); + const targets = config.channels.filter(c => c.events.includes('stream_end')); + if (targets.length === 0) return; + + const embed = new EmbedBuilder() + .setColor(0xED4245) // red + .setTitle('⏹️ Stream beendet') + .addFields( + { name: 'Titel', value: info.title, inline: true }, + { name: 'Streamer', value: info.broadcasterName, inline: true }, + { name: 'Zuschauer', value: String(info.viewerCount), inline: true }, + { name: 'Dauer', value: info.duration, inline: true }, + ) + .setTimestamp(); + + for (const target of targets) { + try { + const channel = await _client.channels.fetch(target.channelId); + if (channel?.type === ChannelType.GuildText) { + await (channel as TextChannel).send({ embeds: [embed] }); + } + } catch (err) { + console.error(`${NB} Failed to send to ${target.channelId}:`, err); + } + } + console.log(`${NB} Stream-end notification sent to ${targets.length} channel(s)`); +} + +// ── Plugin ── + +const notificationsPlugin: Plugin = { + name: 'notifications', + version: '1.0.0', + description: 'Discord Notification Bot', + + async init(ctx) { + _ctx = ctx; + _client = ctx.client; + _publicUrl = process.env.PUBLIC_URL?.replace(/\/$/, '') ?? ''; + console.log(`${NB} Initialized${_publicUrl ? ` (PUBLIC_URL=${_publicUrl})` : ' (no PUBLIC_URL set)'}`); + }, + + async onReady(ctx) { + console.log(`${NB} Bot ready as ${ctx.client.user?.tag}`); + }, + + registerRoutes(app, ctx) { + const requireAdmin = (req: express.Request, res: express.Response, next: () => void): void => { + if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } + if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } + next(); + }; + + // Admin status + app.get('/api/notifications/admin/status', (req, res) => { + if (!ctx.adminPwd) { res.json({ admin: false }); return; } + res.json({ admin: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) }); + }); + + // Admin login + app.post('/api/notifications/admin/login', (req, res) => { + if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } + const { password } = req.body ?? {}; + if (password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; } + const token = signAdminToken(ctx.adminPwd); + res.setHeader('Set-Cookie', `admin=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 86400}`); + res.json({ ok: true }); + }); + + // Admin logout + app.post('/api/notifications/admin/logout', (_req, res) => { + res.setHeader('Set-Cookie', 'admin=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); + res.json({ ok: true }); + }); + + // List available text channels (requires admin) + app.get('/api/notifications/channels', requireAdmin, async (_req, res) => { + if (!ctx.client.isReady()) { + res.status(503).json({ error: 'Bot nicht verbunden' }); + return; + } + const result: Array<{ channelId: string; channelName: string; guildId: string; guildName: string }> = []; + for (const guild of ctx.client.guilds.cache.values()) { + // Filter by allowed guilds if configured + if (ctx.allowedGuildIds.length > 0 && !ctx.allowedGuildIds.includes(guild.id)) continue; + for (const channel of guild.channels.cache.values()) { + if (channel.type === ChannelType.GuildText) { + result.push({ + channelId: channel.id, + channelName: channel.name, + guildId: guild.id, + guildName: guild.name, + }); + } + } + } + res.json({ channels: result }); + }); + + // Get current config + app.get('/api/notifications/config', requireAdmin, (_req, res) => { + const config = getState('notifications_config', { channels: [] }); + res.json(config); + }); + + // Save config + app.post('/api/notifications/config', requireAdmin, (req, res) => { + const { channels } = req.body ?? {}; + if (!Array.isArray(channels)) { res.status(400).json({ error: 'channels array erforderlich' }); return; } + // Validate each channel config + const validChannels: NotifyChannelConfig[] = channels.map((c: any) => ({ + channelId: String(c.channelId || ''), + channelName: String(c.channelName || ''), + guildId: String(c.guildId || ''), + guildName: String(c.guildName || ''), + events: Array.isArray(c.events) ? c.events.filter((e: string) => ['stream_start', 'stream_end'].includes(e)) : [], + })).filter((c: NotifyChannelConfig) => c.channelId && c.events.length > 0); + setState('notifications_config', { channels: validChannels }); + console.log(`${NB} Config saved: ${validChannels.length} channel(s)`); + res.json({ ok: true, channels: validChannels }); + }); + + // Bot status (public) + app.get('/api/notifications/status', (_req, res) => { + res.json({ + online: ctx.client.isReady(), + botTag: ctx.client.user?.tag ?? null, + configuredChannels: getState('notifications_config', { channels: [] }).channels.length, + }); + }); + }, + + getSnapshot() { + return { + notifications: { + online: _client?.isReady() ?? false, + botTag: _client?.user?.tag ?? null, + }, + }; + }, + + async destroy() { + console.log(`${NB} Destroyed`); + }, +}; + +export default notificationsPlugin; diff --git a/server/src/plugins/streaming/index.ts b/server/src/plugins/streaming/index.ts index 20b1b20..d1302fc 100644 --- a/server/src/plugins/streaming/index.ts +++ b/server/src/plugins/streaming/index.ts @@ -3,6 +3,7 @@ import { WebSocketServer, WebSocket } from 'ws'; import crypto from 'node:crypto'; import type { Plugin, PluginContext } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; +import { notifyStreamStart, notifyStreamEnd } from '../notifications/index.js'; // ── Types ── @@ -73,6 +74,19 @@ function endStream(streamId: string, reason: string): void { broadcaster.broadcastStreamId = undefined; } + // Send Discord notification + const durationMs = Date.now() - new Date(stream.startedAt).getTime(); + const durationMin = Math.floor(durationMs / 60000); + const durationH = Math.floor(durationMin / 60); + const durationM = durationMin % 60; + const durationStr = durationH > 0 ? `${durationH}h ${durationM}m` : `${durationM}m`; + notifyStreamEnd({ + broadcasterName: stream.broadcasterName, + title: stream.title, + viewerCount: stream.viewerCount, + duration: durationStr, + }).catch(err => console.error('[Streaming] Notification error:', err)); + streams.delete(streamId); broadcastStreamStatus(); console.log(`[Streaming] Stream "${stream.title}" ended: ${reason}`); @@ -130,6 +144,13 @@ function handleSignalingMessage(client: WsClient, msg: any): void { } } console.log(`[Streaming] ${name} started "${title}" (${streamId.slice(0, 8)})`); + // Send Discord notification + notifyStreamStart({ + streamId, + broadcasterName: name, + title, + hasPassword: password.length > 0, + }).catch(err => console.error('[Streaming] Notification error:', err)); break; } diff --git a/web/src/plugins/streaming/StreamingTab.tsx b/web/src/plugins/streaming/StreamingTab.tsx index 555dd21..839850e 100644 --- a/web/src/plugins/streaming/StreamingTab.tsx +++ b/web/src/plugins/streaming/StreamingTab.tsx @@ -61,6 +61,17 @@ export default function StreamingTab({ data }: { data: any }) { const [openMenu, setOpenMenu] = useState(null); const [copiedId, setCopiedId] = useState(null); + // ── Admin / Notification Config ── + const [showAdmin, setShowAdmin] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); + const [adminPwd, setAdminPwd] = useState(''); + const [adminError, setAdminError] = useState(''); + const [availableChannels, setAvailableChannels] = useState>([]); + const [notifyConfig, setNotifyConfig] = useState>([]); + const [configLoading, setConfigLoading] = useState(false); + const [configSaving, setConfigSaving] = useState(false); + const [notifyStatus, setNotifyStatus] = useState<{ online: boolean; botTag: string | null }>({ online: false, botTag: null }); + // ── Refs ── const wsRef = useRef(null); const clientIdRef = useRef(''); @@ -112,6 +123,18 @@ export default function StreamingTab({ data }: { data: any }) { return () => document.removeEventListener('click', handler); }, [openMenu]); + // Check admin status on mount + useEffect(() => { + fetch('/api/notifications/admin/status', { credentials: 'include' }) + .then(r => r.json()) + .then(d => setIsAdmin(d.admin === true)) + .catch(() => {}); + fetch('/api/notifications/status') + .then(r => r.json()) + .then(d => setNotifyStatus(d)) + .catch(() => {}); + }, []); + // ── Send via WS ── const wsSend = useCallback((d: Record) => { if (wsRef.current?.readyState === WebSocket.OPEN) { @@ -536,6 +559,98 @@ export default function StreamingTab({ data }: { data: any }) { setOpenMenu(null); }, [buildStreamLink]); + // ── Admin functions ── + const adminLogin = useCallback(async () => { + setAdminError(''); + try { + const resp = await fetch('/api/notifications/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: adminPwd }), + credentials: 'include', + }); + if (resp.ok) { + setIsAdmin(true); + setAdminPwd(''); + loadNotifyConfig(); + } else { + const d = await resp.json(); + setAdminError(d.error || 'Fehler'); + } + } catch { + setAdminError('Verbindung fehlgeschlagen'); + } + }, [adminPwd]); + + const adminLogout = useCallback(async () => { + await fetch('/api/notifications/admin/logout', { method: 'POST', credentials: 'include' }); + setIsAdmin(false); + setShowAdmin(false); + }, []); + + const loadNotifyConfig = useCallback(async () => { + setConfigLoading(true); + try { + const [chResp, cfgResp] = await Promise.all([ + fetch('/api/notifications/channels', { credentials: 'include' }), + fetch('/api/notifications/config', { credentials: 'include' }), + ]); + if (chResp.ok) { + const chData = await chResp.json(); + setAvailableChannels(chData.channels || []); + } + if (cfgResp.ok) { + const cfgData = await cfgResp.json(); + setNotifyConfig(cfgData.channels || []); + } + } catch { /* silent */ } + finally { setConfigLoading(false); } + }, []); + + const openAdmin = useCallback(() => { + setShowAdmin(true); + if (isAdmin) loadNotifyConfig(); + }, [isAdmin, loadNotifyConfig]); + + const toggleChannelEvent = useCallback((channelId: string, channelName: string, guildId: string, guildName: string, event: string) => { + setNotifyConfig(prev => { + const existing = prev.find(c => c.channelId === channelId); + if (existing) { + const hasEvent = existing.events.includes(event); + const newEvents = hasEvent + ? existing.events.filter(e => e !== event) + : [...existing.events, event]; + if (newEvents.length === 0) { + return prev.filter(c => c.channelId !== channelId); + } + return prev.map(c => c.channelId === channelId ? { ...c, events: newEvents } : c); + } else { + return [...prev, { channelId, channelName, guildId, guildName, events: [event] }]; + } + }); + }, []); + + const saveNotifyConfig = useCallback(async () => { + setConfigSaving(true); + try { + const resp = await fetch('/api/notifications/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channels: notifyConfig }), + credentials: 'include', + }); + if (resp.ok) { + // brief visual feedback handled by configSaving state + } + } catch { /* silent */ } + finally { setConfigSaving(false); } + }, [notifyConfig]); + + const isChannelEventEnabled = useCallback((channelId: string, event: string): boolean => { + const ch = notifyConfig.find(c => c.channelId === channelId); + return ch?.events.includes(event) ?? false; + }, [notifyConfig]); + // ── Render ── // Fullscreen viewer overlay @@ -619,6 +734,9 @@ export default function StreamingTab({ data }: { data: any }) { {starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'} )} + {streams.length === 0 && !isBroadcasting ? ( @@ -722,6 +840,101 @@ export default function StreamingTab({ data }: { data: any }) { )} + + {/* ── Notification Admin Modal ── */} + {showAdmin && ( +
setShowAdmin(false)}> +
e.stopPropagation()}> +
+

{'\uD83D\uDD14'} Benachrichtigungen

+ +
+ + {!isAdmin ? ( +
+

Admin-Passwort eingeben:

+
+ setAdminPwd(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }} + autoFocus + /> + +
+ {adminError &&

{adminError}

} +
+ ) : ( +
+
+ + {notifyStatus.online + ? <>{'\u2705'} Bot online: {notifyStatus.botTag} + : <>{'\u26A0\uFE0F'} Bot offline — DISCORD_TOKEN_NOTIFICATIONS setzen} + + +
+ + {configLoading ? ( +
Lade Kan{'\u00E4'}le...
+ ) : availableChannels.length === 0 ? ( +
+ {notifyStatus.online + ? 'Keine Text-Kan\u00E4le gefunden. Bot hat m\u00F6glicherweise keinen Zugriff.' + : 'Bot ist nicht verbunden. Bitte DISCORD_TOKEN_NOTIFICATIONS konfigurieren.'} +
+ ) : ( + <> +

+ W{'\u00E4'}hle die Kan{'\u00E4'}le, in die Benachrichtigungen gesendet werden sollen: +

+
+ {availableChannels.map(ch => ( +
+
+ #{ch.channelName} + {ch.guildName} +
+
+ + +
+
+ ))} +
+
+ +
+ + )} +
+ )} +
+
+ )} ); } diff --git a/web/src/plugins/streaming/streaming.css b/web/src/plugins/streaming/streaming.css index 432a891..0dee388 100644 --- a/web/src/plugins/streaming/streaming.css +++ b/web/src/plugins/streaming/streaming.css @@ -471,3 +471,171 @@ color: var(--text-normal); border-color: var(--text-faint); } + +/* ── Admin Button ── */ +.stream-admin-btn { + margin-left: auto; + padding: 8px; + border: none; + border-radius: var(--radius); + background: transparent; + color: var(--text-muted); + font-size: 18px; + cursor: pointer; + transition: all var(--transition); + line-height: 1; +} +.stream-admin-btn:hover { color: var(--text-normal); background: var(--bg-tertiary); } + +/* ── Admin Overlay ── */ +.stream-admin-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .6); + display: flex; + align-items: center; + justify-content: center; + z-index: 999; + animation: fadeIn .15s ease; +} +@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } + +.stream-admin-panel { + background: var(--bg-secondary); + border-radius: 12px; + width: 560px; + max-width: 95vw; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, .4); + overflow: hidden; +} + +.stream-admin-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--bg-tertiary); +} +.stream-admin-header h3 { margin: 0; font-size: 16px; color: var(--text-normal); } +.stream-admin-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 16px; + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius); +} +.stream-admin-close:hover { color: var(--text-normal); background: var(--bg-tertiary); } + +/* ── Admin Login ── */ +.stream-admin-login { padding: 24px 20px; } +.stream-admin-login p { margin: 0 0 12px; color: var(--text-muted); font-size: 14px; } +.stream-admin-login-row { display: flex; gap: 8px; } +.stream-admin-login-row .stream-input { flex: 1; } +.stream-admin-error { color: #ed4245; font-size: 13px; margin-top: 8px; } + +/* ── Admin Content ── */ +.stream-admin-content { padding: 16px 20px; overflow-y: auto; } + +.stream-admin-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--bg-tertiary); +} +.stream-admin-status { font-size: 13px; color: var(--text-muted); } +.stream-admin-status b { color: var(--text-normal); } +.stream-admin-logout { + padding: 4px 12px; + border: 1px solid var(--bg-tertiary); + border-radius: var(--radius); + background: transparent; + color: var(--text-muted); + font-size: 12px; + cursor: pointer; +} +.stream-admin-logout:hover { color: #ed4245; border-color: #ed4245; } + +.stream-admin-loading, .stream-admin-empty { + text-align: center; + padding: 32px 16px; + color: var(--text-muted); + font-size: 14px; +} +.stream-admin-hint { margin: 0 0 12px; color: var(--text-muted); font-size: 13px; } + +/* ── Channel List ── */ +.stream-admin-channel-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 45vh; + overflow-y: auto; +} +.stream-admin-channel { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: var(--bg-deep); + border-radius: var(--radius); + gap: 12px; +} +.stream-admin-channel-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.stream-admin-channel-name { + font-size: 14px; + font-weight: 600; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.stream-admin-channel-guild { + font-size: 11px; + color: var(--text-faint); +} +.stream-admin-channel-events { + display: flex; + gap: 6px; + flex-shrink: 0; +} +.stream-admin-event-toggle { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 14px; + font-size: 12px; + color: var(--text-muted); + background: var(--bg-secondary); + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} +.stream-admin-event-toggle input { display: none; } +.stream-admin-event-toggle:hover { color: var(--text-normal); } +.stream-admin-event-toggle.active { + background: var(--accent); + color: #fff; +} + +/* ── Actions ── */ +.stream-admin-actions { + margin-top: 16px; + display: flex; + justify-content: flex-end; +} +.stream-admin-save { + padding: 8px 24px; +}