Commit current project
This commit is contained in:
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Environment Configuration for Mixchat
|
||||||
|
|
||||||
|
# Twitch API Credentials
|
||||||
|
# Get from: https://dev.twitch.tv/console/apps
|
||||||
|
TWITCH_CLIENT_ID=your_twitch_client_id_here
|
||||||
|
TWITCH_CLIENT_SECRET=your_twitch_client_secret_here
|
||||||
|
TWITCH_REDIRECT_URI=http://localhost:3000/auth/twitch/callback
|
||||||
|
|
||||||
|
# YouTube API Credentials
|
||||||
|
# Get from: https://console.cloud.google.com/
|
||||||
|
YOUTUBE_API_KEY=your_youtube_api_key_here
|
||||||
|
YOUTUBE_CLIENT_ID=your_youtube_client_id_here
|
||||||
|
YOUTUBE_CLIENT_SECRET=your_youtube_client_secret_here
|
||||||
|
YOUTUBE_REDIRECT_URI=http://localhost:3000/auth/youtube/callback
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
.vercel/
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.vscode/settings.json
|
||||||
|
.idea/
|
||||||
|
*_FEATURE.md
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 grepfs17
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
35
astro.config.mjs
Normal file
35
astro.config.mjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import react from '@astrojs/react';
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [react()],
|
||||||
|
adapter: process.env.NODE_ENV === 'production' ? node({ mode: 'production' }) : node({ mode: 'development' }),
|
||||||
|
output: 'server',
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 3000,
|
||||||
|
allowedHosts: ['mixchat.local'],
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
resolve: {
|
||||||
|
noExternal: ['fs', 'path', 'url', 'util']
|
||||||
|
},
|
||||||
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: true,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id) return;
|
||||||
|
if (id.includes('/node_modules/react-icons/') || id.includes('\\node_modules\\react-icons\\')) return 'icons';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunkSizeWarningLimit: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
50
package.json
Normal file
50
package.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "mixchat",
|
||||||
|
"version": "0.5.0-beta",
|
||||||
|
"description": "Advanced Twitch chat client with support for 7TV, BTTV, and FFZ emotes. Stream replies, badges and basic YouTube chat support.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"test": "vitest",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@twurple/api": "^8.0.3",
|
||||||
|
"@twurple/auth": "^8.0.3",
|
||||||
|
"@twurple/chat": "^8.0.3",
|
||||||
|
"astro": "^6.1.1",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"twitch-m3u8": "^1.1.5",
|
||||||
|
"youtube-chat": "^2.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astrojs/node": "^10.0.3",
|
||||||
|
"@astrojs/react": "^5.0.2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^14.3.1",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^22.12.0",
|
||||||
|
"@types/react": "^18.0.21",
|
||||||
|
"@types/react-dom": "^18.0.6",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"pm2": "^6.0.14",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^1.6.1"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"tar": "7.5.9",
|
||||||
|
"esbuild": "0.25.0",
|
||||||
|
"ajv": "8.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6477
pnpm-lock.yaml
generated
Normal file
6477
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
pnpm-workspace.yaml
Normal file
9
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
packages:
|
||||||
|
- '.'
|
||||||
|
ignoredBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
- sharp
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- esbuild
|
||||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
6
public/robots.txt
Normal file
6
public/robots.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /auth/
|
||||||
|
|
||||||
|
Sitemap: https://mixchat.app/sitemap.xml
|
||||||
1032
src/components/AppContainer.tsx
Normal file
1032
src/components/AppContainer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
153
src/components/ChannelSelector.tsx
Normal file
153
src/components/ChannelSelector.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface TwitchChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
title: string;
|
||||||
|
profileImageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelSelectorProps {
|
||||||
|
channels: TwitchChannel[];
|
||||||
|
selectedChannel: string;
|
||||||
|
onChannelChange: (channelId: string) => void;
|
||||||
|
platform: 'twitch' | 'youtube';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChannelSelector({
|
||||||
|
channels,
|
||||||
|
selectedChannel,
|
||||||
|
onChannelChange,
|
||||||
|
platform,
|
||||||
|
}: ChannelSelectorProps) {
|
||||||
|
const [manualChannel, setManualChannel] = useState('');
|
||||||
|
|
||||||
|
if (channels.length === 0) return null;
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
padding: '8px',
|
||||||
|
background: '#1a1a1a',
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#94a3b8',
|
||||||
|
marginBottom: '8px',
|
||||||
|
display: 'block',
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap' as const,
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
},
|
||||||
|
channelItem: (isSelected: boolean) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '6px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: isSelected ? '2px solid #3b82f6' : '2px solid transparent',
|
||||||
|
backgroundColor: isSelected ? '#1e3a5f' : 'transparent',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
}),
|
||||||
|
profileImage: {
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
objectFit: 'cover' as const,
|
||||||
|
marginBottom: '4px',
|
||||||
|
border: '2px solid #333333',
|
||||||
|
},
|
||||||
|
channelName: {
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
maxWidth: '60px',
|
||||||
|
overflow: 'hidden' as const,
|
||||||
|
textOverflow: 'ellipsis' as const,
|
||||||
|
whiteSpace: 'nowrap' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualJoin = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (manualChannel.trim()) {
|
||||||
|
onChannelChange(manualChannel.trim().toLowerCase());
|
||||||
|
setManualChannel('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container as React.CSSProperties}>
|
||||||
|
<label style={styles.label}>
|
||||||
|
{platform === 'twitch' ? '📺 Channels' : '▶ Streams'}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={styles.listContainer as React.CSSProperties}>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<div
|
||||||
|
key={channel.id}
|
||||||
|
style={styles.channelItem(selectedChannel === channel.id) as React.CSSProperties}
|
||||||
|
onClick={() => onChannelChange(channel.id)}
|
||||||
|
title={channel.displayName}
|
||||||
|
>
|
||||||
|
{channel.profileImageUrl && (
|
||||||
|
<img
|
||||||
|
src={channel.profileImageUrl}
|
||||||
|
alt={channel.displayName}
|
||||||
|
style={styles.profileImage as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span style={styles.channelName as React.CSSProperties}>
|
||||||
|
{channel.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '12px' }}>
|
||||||
|
<label style={styles.label}>Or join manually</label>
|
||||||
|
<form onSubmit={handleManualJoin} style={{ display: 'flex', gap: '3px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manualChannel}
|
||||||
|
onChange={(e) => setManualChannel(e.target.value)}
|
||||||
|
placeholder="Enter channel name"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px',
|
||||||
|
border: '1px solid #333333',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
minWidth: '0',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!manualChannel.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
cursor: manualChannel.trim() ? 'pointer' : 'not-allowed',
|
||||||
|
backgroundColor: manualChannel.trim() ? '#555555' : '#333333',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1024
src/components/ChatApp.tsx
Normal file
1024
src/components/ChatApp.tsx
Normal file
File diff suppressed because it is too large
Load Diff
519
src/components/ChatDisplay.tsx
Normal file
519
src/components/ChatDisplay.tsx
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, memo } from 'react';
|
||||||
|
import { MdStars, MdAutorenew, MdCardGiftcard, MdRefresh, MdReply, MdContentCopy, MdRedeem } from 'react-icons/md';
|
||||||
|
import { GiHeartBeats } from 'react-icons/gi';
|
||||||
|
import { FaUsers } from 'react-icons/fa';
|
||||||
|
import type { ChatMessage, Platform } from '../lib/types';
|
||||||
|
import { parseMessageWithEmotes } from '../lib/emotes';
|
||||||
|
|
||||||
|
interface ChatDisplayProps {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
isLoading: boolean;
|
||||||
|
selectedPlatforms: Platform[];
|
||||||
|
onReply?: (message: ChatMessage) => void;
|
||||||
|
channelName?: string;
|
||||||
|
channelDisplayName?: string;
|
||||||
|
streamTitle?: string;
|
||||||
|
isStreamLive?: boolean;
|
||||||
|
streamUptime?: string;
|
||||||
|
viewerCount?: number | null;
|
||||||
|
onRefreshEmotes?: () => void;
|
||||||
|
isRefreshingEmotes?: boolean;
|
||||||
|
onToggleSidebar?: () => void;
|
||||||
|
sidebarVisible?: boolean;
|
||||||
|
onCopyMessage?: () => void;
|
||||||
|
showStreamEmbed?: boolean;
|
||||||
|
onToggleStreamEmbed?: () => void;
|
||||||
|
currentUserDisplayName?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
/** When provided (unified chat mode), renders this instead of the normal messages area */
|
||||||
|
unifiedChatSlot?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatDisplay({
|
||||||
|
messages,
|
||||||
|
isLoading,
|
||||||
|
selectedPlatforms,
|
||||||
|
onReply,
|
||||||
|
channelName,
|
||||||
|
channelDisplayName,
|
||||||
|
streamTitle,
|
||||||
|
isStreamLive,
|
||||||
|
streamUptime,
|
||||||
|
viewerCount,
|
||||||
|
onRefreshEmotes,
|
||||||
|
isRefreshingEmotes = false,
|
||||||
|
onToggleSidebar,
|
||||||
|
sidebarVisible = true,
|
||||||
|
onCopyMessage,
|
||||||
|
showStreamEmbed = true,
|
||||||
|
onToggleStreamEmbed,
|
||||||
|
currentUserDisplayName,
|
||||||
|
fontSize = 14,
|
||||||
|
unifiedChatSlot,
|
||||||
|
}: ChatDisplayProps) {
|
||||||
|
const [filteredMessages, setFilteredMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
const [newMessagesCount, setNewMessagesCount] = useState(0);
|
||||||
|
const [previousMessageCount, setPreviousMessageCount] = useState(0);
|
||||||
|
const messagesContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const authorColorById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
messages.forEach((message) => {
|
||||||
|
if (message.authorColor) {
|
||||||
|
map.set(message.id, message.authorColor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let filtered = messages;
|
||||||
|
|
||||||
|
// Remove optimistic placeholders when a matching real server message exists.
|
||||||
|
filtered = filtered.filter((msg, _, arr) => {
|
||||||
|
if (!msg.optimistic) return true;
|
||||||
|
|
||||||
|
// If there's a non-optimistic message with same content, reply target,
|
||||||
|
// and userId, prefer the real one and drop this optimistic placeholder.
|
||||||
|
const hasReal = arr.some((other) =>
|
||||||
|
other !== msg &&
|
||||||
|
!other.optimistic &&
|
||||||
|
other.isCurrentUser &&
|
||||||
|
other.userId === msg.userId &&
|
||||||
|
other.content === msg.content &&
|
||||||
|
((other.replyTo?.id || '') === (msg.replyTo?.id || ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
return !hasReal;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedPlatforms.length === 0) {
|
||||||
|
setFilteredMessages(filtered);
|
||||||
|
} else {
|
||||||
|
filtered = filtered.filter((msg) => selectedPlatforms.includes(msg.platform));
|
||||||
|
setFilteredMessages(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update new messages count when not at bottom
|
||||||
|
if (!isAtBottom) {
|
||||||
|
const newCount = filtered.length - previousMessageCount;
|
||||||
|
if (newCount > 0) {
|
||||||
|
setNewMessagesCount(newCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messages, selectedPlatforms, isAtBottom, previousMessageCount]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when messages change (only if user is at bottom)
|
||||||
|
useEffect(() => {
|
||||||
|
if (messagesContainerRef.current && isAtBottom) {
|
||||||
|
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [filteredMessages, isAtBottom]);
|
||||||
|
|
||||||
|
// Track scroll position
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (messagesContainerRef.current) {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
|
||||||
|
// Consider user at bottom if within 50px of bottom
|
||||||
|
const atBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||||
|
setIsAtBottom(atBottom);
|
||||||
|
|
||||||
|
// Reset new messages count when user scrolls back to bottom
|
||||||
|
if (atBottom) {
|
||||||
|
setNewMessagesCount(0);
|
||||||
|
setPreviousMessageCount(filteredMessages.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = messagesContainerRef.current;
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('scroll', handleScroll);
|
||||||
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
}, [filteredMessages.length]);
|
||||||
|
|
||||||
|
// Handle emote tooltip on hover
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEmoteHover = (e: Event) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
|
||||||
|
if (!target.classList.contains('chat-emote')) return;
|
||||||
|
|
||||||
|
if (e.type === 'mouseenter') {
|
||||||
|
const tooltipUrl = target.getAttribute('data-tooltip-url');
|
||||||
|
const emoteName = target.getAttribute('data-emote-name') || 'Emote';
|
||||||
|
|
||||||
|
if (tooltipUrl) {
|
||||||
|
const tooltip = document.createElement('div');
|
||||||
|
tooltip.className = 'chat-emote-tooltip';
|
||||||
|
tooltip.id = 'emote-tooltip';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = tooltipUrl;
|
||||||
|
img.alt = emoteName;
|
||||||
|
tooltip.appendChild(img);
|
||||||
|
|
||||||
|
const nameDiv = document.createElement('div');
|
||||||
|
nameDiv.className = 'chat-emote-tooltip-name';
|
||||||
|
nameDiv.textContent = emoteName;
|
||||||
|
tooltip.appendChild(nameDiv);
|
||||||
|
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
|
||||||
|
// Position tooltip near the mouse
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
tooltip.style.left = (rect.left + window.scrollX) + 'px';
|
||||||
|
tooltip.style.top = (rect.top + window.scrollY - tooltip.offsetHeight - 10) + 'px';
|
||||||
|
}
|
||||||
|
} else if (e.type === 'mouseleave') {
|
||||||
|
const tooltip = document.getElementById('emote-tooltip');
|
||||||
|
if (tooltip) {
|
||||||
|
tooltip.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const messagesContainer = messagesContainerRef.current;
|
||||||
|
if (messagesContainer) {
|
||||||
|
messagesContainer.addEventListener('mouseenter', handleEmoteHover, true);
|
||||||
|
messagesContainer.addEventListener('mouseleave', handleEmoteHover, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (messagesContainer) {
|
||||||
|
messagesContainer.removeEventListener('mouseenter', handleEmoteHover, true);
|
||||||
|
messagesContainer.removeEventListener('mouseleave', handleEmoteHover, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
// Helper to render message icon for special events
|
||||||
|
const renderMessageIcon = (msg: ChatMessage) => {
|
||||||
|
if (!msg.messageType) return null;
|
||||||
|
|
||||||
|
const iconConfig: Record<string, { title: string; color: string }> = {
|
||||||
|
cheer: { title: 'Cheer', color: '#FFB700' },
|
||||||
|
subscription: { title: 'Subscription', color: '#9147FF' },
|
||||||
|
resub: { title: 'Resub', color: '#9147FF' },
|
||||||
|
subgift: { title: 'Subgift', color: '#9147FF' },
|
||||||
|
redemption: { title: 'Channel Points Redemption', color: '#9147FF' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = iconConfig[msg.messageType];
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
let iconElement;
|
||||||
|
switch (msg.messageType) {
|
||||||
|
case 'cheer':
|
||||||
|
iconElement = <GiHeartBeats size={16} />;
|
||||||
|
break;
|
||||||
|
case 'subscription':
|
||||||
|
iconElement = <MdStars size={16} />;
|
||||||
|
break;
|
||||||
|
case 'resub':
|
||||||
|
iconElement = <MdAutorenew size={16} />;
|
||||||
|
break;
|
||||||
|
case 'subgift':
|
||||||
|
iconElement = <MdCardGiftcard size={16} />;
|
||||||
|
break;
|
||||||
|
case 'redemption':
|
||||||
|
iconElement = <MdRedeem size={16} />;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={config.title}
|
||||||
|
style={{
|
||||||
|
marginRight: '6px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: config.color,
|
||||||
|
filter: `drop-shadow(0 0 2px ${config.color})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{iconElement}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to render badges
|
||||||
|
const renderBadges = (msg: ChatMessage) => {
|
||||||
|
// Prefer detailed badge info if available (contains titles/descriptions)
|
||||||
|
if (msg.badgeDetails && Object.keys(msg.badgeDetails).length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
|
||||||
|
{Object.entries(msg.badgeDetails).map(([name, detail]) => (
|
||||||
|
<img
|
||||||
|
key={name}
|
||||||
|
src={detail.url}
|
||||||
|
alt={detail.title}
|
||||||
|
className="badge"
|
||||||
|
title={detail.title}
|
||||||
|
loading="eager"
|
||||||
|
style={{
|
||||||
|
width: '18px',
|
||||||
|
height: '18px',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for platform-agnostic or historical badges
|
||||||
|
if (!msg.badges || Object.keys(msg.badges).length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
|
||||||
|
{Object.entries(msg.badges).map(([name, badgeUrl]) => {
|
||||||
|
if (!badgeUrl) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={`${name}`}
|
||||||
|
src={badgeUrl}
|
||||||
|
alt={name}
|
||||||
|
className="badge"
|
||||||
|
title={name}
|
||||||
|
loading="eager"
|
||||||
|
style={{
|
||||||
|
width: '18px',
|
||||||
|
height: '18px',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-display">
|
||||||
|
<div className="chat-header">
|
||||||
|
<div className="chat-header-content">
|
||||||
|
{onToggleSidebar && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
className="sidebar-toggle-button"
|
||||||
|
title={sidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
|
||||||
|
>
|
||||||
|
{sidebarVisible ? '◀' : '▶'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!showStreamEmbed && onToggleStreamEmbed && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleStreamEmbed}
|
||||||
|
className="show-stream-button"
|
||||||
|
title="Show stream embed"
|
||||||
|
>
|
||||||
|
Show Stream
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h2>
|
||||||
|
{isStreamLive ? (
|
||||||
|
<span className="status-badge-live">
|
||||||
|
<span className="status-dot pulse" />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="status-badge-offline">
|
||||||
|
<span className="status-dot" />
|
||||||
|
OFFLINE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`https://twitch.tv/${channelName}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="channel-link"
|
||||||
|
>
|
||||||
|
{channelDisplayName || channelName || 'Chat'}
|
||||||
|
</a>
|
||||||
|
{viewerCount !== undefined && viewerCount !== null && (
|
||||||
|
<span className="viewer-count-display">
|
||||||
|
<FaUsers size={14} /> {viewerCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{streamUptime && isStreamLive && (
|
||||||
|
<span className="uptime-display">
|
||||||
|
({streamUptime})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{streamTitle && <span className="stream-title">{streamTitle}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="chat-header-actions">
|
||||||
|
{onRefreshEmotes && (
|
||||||
|
<button
|
||||||
|
onClick={onRefreshEmotes}
|
||||||
|
disabled={isRefreshingEmotes}
|
||||||
|
className="header-button"
|
||||||
|
title={isRefreshingEmotes ? 'Reloading emotes...' : 'Reload all emotes (clears cache)'}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
<MdRefresh size={14} style={{ animation: isRefreshingEmotes ? 'spin 1s linear infinite' : 'none' }} />
|
||||||
|
{isRefreshingEmotes ? 'Reloading...' : 'Reload Emotes'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isLoading && <span className="loading-indicator">●</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unifiedChatSlot ? (
|
||||||
|
unifiedChatSlot
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="messages-container"
|
||||||
|
ref={messagesContainerRef}
|
||||||
|
style={{ '--chat-font-size': `${fontSize}px` } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{filteredMessages.length === 0 ? (
|
||||||
|
<div className="no-messages">
|
||||||
|
<p>No messages yet. Start chatting!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredMessages.map((msg) => {
|
||||||
|
const isMentioned = currentUserDisplayName && msg.content.includes(currentUserDisplayName);
|
||||||
|
const messageClasses = [
|
||||||
|
'message',
|
||||||
|
msg.messageType || 'chat',
|
||||||
|
msg.isPreloaded ? 'preloaded' : '',
|
||||||
|
msg.isBot ? 'bot' : '',
|
||||||
|
((msg.mentionsUser || isMentioned) && !msg.isBot) ? 'mention' : ''
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className={messageClasses}>
|
||||||
|
{msg.replyTo && (
|
||||||
|
<div className="reply-info">
|
||||||
|
<span>response to{' '}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: authorColorById.get(msg.replyTo.id) || '#94a3b8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@{msg.replyTo.author}
|
||||||
|
</span>
|
||||||
|
{msg.replyTo.content && (
|
||||||
|
<>
|
||||||
|
{': '}
|
||||||
|
<span
|
||||||
|
className="reply-target-content"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: parseMessageWithEmotes(msg.replyTo.content)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="message-single-line">
|
||||||
|
<span className="timestamp">
|
||||||
|
{msg.timestamp.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
{msg.authorAvatar && (
|
||||||
|
<img
|
||||||
|
src={msg.authorAvatar}
|
||||||
|
alt={msg.author}
|
||||||
|
className="avatar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renderMessageIcon(msg)}
|
||||||
|
{renderBadges(msg)}
|
||||||
|
<a
|
||||||
|
href={`https://twitch.tv/${msg.author}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`author author-link ${msg.isCurrentUser ? 'self' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: msg.isCurrentUser ? '#ffffff' : (msg.authorColor || '#e2e8f0'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.author}
|
||||||
|
</a>
|
||||||
|
<span className="message-separator">:</span>
|
||||||
|
<span
|
||||||
|
className="message-text"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: parseMessageWithEmotes(msg.content, msg.emotes),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="message-actions">
|
||||||
|
{onReply && (
|
||||||
|
<button
|
||||||
|
onClick={() => onReply(msg)}
|
||||||
|
className="action-button"
|
||||||
|
title="Reply to this message"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
|
||||||
|
>
|
||||||
|
<MdReply size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(msg.content).then(() => {
|
||||||
|
onCopyMessage?.();
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback for environments without clipboard API
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = msg.content;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
onCopyMessage?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="action-button"
|
||||||
|
title="Copy message"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
|
||||||
|
>
|
||||||
|
<MdContentCopy size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isAtBottom && !unifiedChatSlot && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (messagesContainerRef.current) {
|
||||||
|
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
||||||
|
setIsAtBottom(true);
|
||||||
|
setNewMessagesCount(0);
|
||||||
|
setPreviousMessageCount(filteredMessages.length);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="go-to-bottom-button"
|
||||||
|
title="Go to latest messages"
|
||||||
|
>
|
||||||
|
{newMessagesCount > 0 ? `New Messages (${newMessagesCount})` : 'New Messages'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ChatDisplay);
|
||||||
271
src/components/EmbedConfigModal.tsx
Normal file
271
src/components/EmbedConfigModal.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FaTimes } from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface EmbedConfigModalProps {
|
||||||
|
embedDefaultOpen: boolean;
|
||||||
|
onEmbedDefaultOpenChange: (value: boolean) => void;
|
||||||
|
unifyChatEnabled: boolean;
|
||||||
|
onUnifyChatEnabledChange: (value: boolean) => void;
|
||||||
|
fontSize: number;
|
||||||
|
onFontSizeChange: (value: number) => void;
|
||||||
|
mentionSoundEnabled: boolean;
|
||||||
|
onMentionSoundEnabledChange: (value: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmbedConfigModal({
|
||||||
|
embedDefaultOpen,
|
||||||
|
onEmbedDefaultOpenChange,
|
||||||
|
unifyChatEnabled,
|
||||||
|
onUnifyChatEnabledChange,
|
||||||
|
fontSize,
|
||||||
|
onFontSizeChange,
|
||||||
|
mentionSoundEnabled,
|
||||||
|
onMentionSoundEnabledChange,
|
||||||
|
onClose,
|
||||||
|
}: EmbedConfigModalProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: '0',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: '1000',
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #333333',
|
||||||
|
padding: '24px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
width: '90%',
|
||||||
|
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.8)',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: '#e2e8f0',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Global Settings
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#999',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
lineHeight: '1',
|
||||||
|
}}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<FaTimes size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '12px',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Chat Font Size: <span style={{ color: '#6366f1', marginLeft: '4px' }}>{fontSize}px</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<span style={{ fontSize: '12px', color: '#94a3b8' }}>12px</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="12"
|
||||||
|
max="32"
|
||||||
|
step="1"
|
||||||
|
value={fontSize}
|
||||||
|
onChange={(e) => onFontSizeChange(parseInt(e.target.value))}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
accentColor: '#6366f1',
|
||||||
|
height: '4px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '12px', color: '#94a3b8' }}>32px</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Adjust the size of the text in the chat window.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={embedDefaultOpen}
|
||||||
|
onChange={(e) => onEmbedDefaultOpenChange(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
width: '18px',
|
||||||
|
height: '18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
accentColor: '#6366f1',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '14px' }}>
|
||||||
|
Open stream embed by default
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '12px',
|
||||||
|
margin: '8px 0 0 30px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
If enabled, the stream embed will be visible when you open Mixchat. If disabled, the chat will take up the full width.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={unifyChatEnabled}
|
||||||
|
onChange={(e) => onUnifyChatEnabledChange(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
width: '18px',
|
||||||
|
height: '18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
accentColor: '#6366f1',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '14px' }}>
|
||||||
|
Unify chats
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '12px',
|
||||||
|
margin: '8px 0 0 30px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
When enabled, Twitch and YouTube messages appear together in a single chronological feed. Each platform keeps its own styling and features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mentionSoundEnabled}
|
||||||
|
onChange={(e) => onMentionSoundEnabledChange(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
width: '18px',
|
||||||
|
height: '18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
accentColor: '#6366f1',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '14px' }}>
|
||||||
|
Play sound on mention
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '12px',
|
||||||
|
margin: '8px 0 0 30px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Plays a notification sound when someone mentions your name or replies to your message.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
border: '1px solid #333333',
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.backgroundColor = '#1a1a1a';
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.borderColor = '#555555';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.backgroundColor = '#0a0a0a';
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.borderColor = '#333333';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
src/components/EmoteMenu.tsx
Normal file
223
src/components/EmoteMenu.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import React, { useState, useRef, useEffect, memo } from 'react';
|
||||||
|
import { FaSearch } from 'react-icons/fa';
|
||||||
|
import type { EmotesBySource } from '../lib/emotes';
|
||||||
|
import { toggleDisableEmote, getDisabledEmotes } from '../lib/emotes';
|
||||||
|
import '../styles/EmoteMenu.css';
|
||||||
|
|
||||||
|
interface EmoteMenuProps {
|
||||||
|
emotes: EmotesBySource;
|
||||||
|
onSelectEmote: (emoteName: string) => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmoteMenu({ emotes, onSelectEmote, isOpen, onClose }: EmoteMenuProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filteredEmotes, setFilteredEmotes] = useState<EmotesBySource>(emotes);
|
||||||
|
const [activeSection, setActiveSection] = useState<'twitch' | '7tv' | 'bttv' | 'ffz'>('twitch');
|
||||||
|
const [disabledSet, setDisabledSet] = useState<Set<string>>(new Set());
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({
|
||||||
|
twitch: null,
|
||||||
|
'7tv': null,
|
||||||
|
bttv: null,
|
||||||
|
ffz: null,
|
||||||
|
});
|
||||||
|
const scrollToSection = (section: string) => {
|
||||||
|
setActiveSection(section as any);
|
||||||
|
const sectionElement = sectionRefs.current[section];
|
||||||
|
if (sectionElement && contentRef.current) {
|
||||||
|
const scrollTop = sectionElement.offsetTop - contentRef.current.offsetTop;
|
||||||
|
contentRef.current.scrollTo({ top: scrollTop, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter emotes based on search query
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setFilteredEmotes(emotes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerQuery = searchQuery.toLowerCase();
|
||||||
|
setFilteredEmotes({
|
||||||
|
twitch: emotes.twitch.filter(e => e.name.toLowerCase().includes(lowerQuery)),
|
||||||
|
'7tv': emotes['7tv'].filter(e => e.name.toLowerCase().includes(lowerQuery)),
|
||||||
|
bttv: emotes.bttv.filter(e => e.name.toLowerCase().includes(lowerQuery)),
|
||||||
|
ffz: emotes.ffz.filter(e => e.name.toLowerCase().includes(lowerQuery)),
|
||||||
|
});
|
||||||
|
}, [searchQuery, emotes]);
|
||||||
|
|
||||||
|
// Reset search and display all emotes when menu opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSearchQuery('');
|
||||||
|
setActiveSection('twitch');
|
||||||
|
setFilteredEmotes(emotes);
|
||||||
|
setDisabledSet(getDisabledEmotes());
|
||||||
|
if (searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, emotes]);
|
||||||
|
|
||||||
|
// Update disabled emotes when global emote state changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setDisabledSet(getDisabledEmotes());
|
||||||
|
window.addEventListener('emotes-reloaded', handler);
|
||||||
|
return () => window.removeEventListener('emotes-reloaded', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Handle keyboard escape
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const sources = [
|
||||||
|
{ key: 'twitch', label: 'Twitch', color: '#9146ff' },
|
||||||
|
{ key: '7tv' as const, label: '7TV', color: '#00d4ff' },
|
||||||
|
{ key: 'bttv', label: 'BTTV', color: '#34ae7b' },
|
||||||
|
{ key: 'ffz', label: 'FFZ', color: '#faa61a' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="emote-menu-overlay">
|
||||||
|
<div ref={menuRef} className="emote-menu-modal">
|
||||||
|
<div className="emote-menu-header">
|
||||||
|
<h2>Emotes</h2>
|
||||||
|
<button className="emote-menu-close" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="emote-menu-search">
|
||||||
|
<FaSearch size={16} style={{ color: '#94a3b8' }} />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search emotes..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="emote-search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="emote-menu-tabs">
|
||||||
|
{sources.map(({ key, label, color }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className={`emote-tab ${activeSection === key ? 'active' : ''}`}
|
||||||
|
onClick={() => scrollToSection(key)}
|
||||||
|
style={{
|
||||||
|
color: activeSection === key ? color : '#cbd5e1',
|
||||||
|
borderBottomColor: activeSection === key ? color : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="emote-menu-content" ref={contentRef}>
|
||||||
|
{sources.map(({ key, label, color }) => {
|
||||||
|
const sourceEmotes = filteredEmotes[key];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="emote-source-section"
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) sectionRefs.current[key] = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="emote-source-header">
|
||||||
|
<div
|
||||||
|
className="emote-source-dot"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<h3 style={{ color }}>{label}</h3>
|
||||||
|
<span className="emote-source-count">({sourceEmotes.length})</span>
|
||||||
|
</div>
|
||||||
|
{sourceEmotes.length > 0 ? (
|
||||||
|
<div className="emote-grid">
|
||||||
|
{sourceEmotes.map((emote) => {
|
||||||
|
const isDisabled = disabledSet.has(emote.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={emote.name}
|
||||||
|
className={`emote-item ${isDisabled ? 'emote-disabled' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isDisabled) {
|
||||||
|
onSelectEmote(emote.name);
|
||||||
|
setSearchQuery('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={emote.name}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="emote-disable-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleDisableEmote(emote.name);
|
||||||
|
setDisabledSet(getDisabledEmotes());
|
||||||
|
}}
|
||||||
|
title={isDisabled ? 'Enable emote' : 'Disable emote'}
|
||||||
|
>
|
||||||
|
{isDisabled ? 'Enable' : 'Disable'}
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={emote.url}
|
||||||
|
alt={emote.name}
|
||||||
|
className="emote-image"
|
||||||
|
/>
|
||||||
|
<span className="emote-name">{emote.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="emote-no-results-section">
|
||||||
|
No {label} emotes available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{Object.values(filteredEmotes).every(arr => arr.length === 0) && (
|
||||||
|
<div className="emote-no-results">
|
||||||
|
No emotes found matching "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(EmoteMenu);
|
||||||
48
src/components/LiveNotification.tsx
Normal file
48
src/components/LiveNotification.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FaTimes } from 'react-icons/fa';
|
||||||
|
import type { LiveStreamer } from '../lib/streamMonitor';
|
||||||
|
import '../styles/LiveNotification.css';
|
||||||
|
|
||||||
|
interface LiveNotificationProps {
|
||||||
|
streamer: LiveStreamer;
|
||||||
|
onViewNow: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveNotification({
|
||||||
|
streamer,
|
||||||
|
onViewNow,
|
||||||
|
onClose,
|
||||||
|
}: LiveNotificationProps) {
|
||||||
|
return (
|
||||||
|
<div className="live-notification">
|
||||||
|
<div className="live-notification-header">
|
||||||
|
<div className="live-notification-title">
|
||||||
|
<span className="live-badge">LIVE</span>
|
||||||
|
<span className="streamer-name">{streamer.displayName}</span>
|
||||||
|
</div>
|
||||||
|
<button className="live-notification-close" onClick={onClose} aria-label="Close">
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{streamer.profileImageUrl && (
|
||||||
|
<img
|
||||||
|
src={streamer.profileImageUrl}
|
||||||
|
alt={streamer.displayName}
|
||||||
|
className="streamer-avatar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{streamer.title && <div className="stream-title">{streamer.title}</div>}
|
||||||
|
|
||||||
|
{streamer.viewerCount && (
|
||||||
|
<div className="viewer-count">👥 {streamer.viewerCount.toLocaleString()} watching</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="view-now-button" onClick={onViewNow}>
|
||||||
|
View Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
285
src/components/LoginPanel.tsx
Normal file
285
src/components/LoginPanel.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
import { FaStar, FaRegStar, FaUsers, FaEye, FaYoutube, FaCog } from 'react-icons/fa';
|
||||||
|
import EmbedConfigModal from './EmbedConfigModal';
|
||||||
|
|
||||||
|
interface TwitchChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
title: string;
|
||||||
|
profileImageUrl?: string;
|
||||||
|
viewerCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginPanelProps {
|
||||||
|
twitchConnected: boolean;
|
||||||
|
twitchUser?: string;
|
||||||
|
twitchProfileImage?: string;
|
||||||
|
twitchUserName?: string;
|
||||||
|
twitchChannels?: TwitchChannel[];
|
||||||
|
selectedTwitchChannel?: string;
|
||||||
|
favoriteChannels?: string[];
|
||||||
|
onChannelChange?: (channelId: string) => void;
|
||||||
|
onToggleFavorite?: (channelId: string) => void;
|
||||||
|
onTwitchLogin: () => void;
|
||||||
|
onTwitchLogout: () => void;
|
||||||
|
youtubeConnected?: boolean;
|
||||||
|
youtubeUser?: string;
|
||||||
|
onYoutubeLogin?: () => void;
|
||||||
|
onYoutubeLogout?: () => void;
|
||||||
|
onManageWatchlist?: () => void;
|
||||||
|
onManageYoutubeLinks?: () => void;
|
||||||
|
embedDefaultOpen?: boolean;
|
||||||
|
onEmbedDefaultOpenChange?: (value: boolean) => void;
|
||||||
|
unifyChatEnabled?: boolean;
|
||||||
|
onUnifyChatEnabledChange?: (value: boolean) => void;
|
||||||
|
fontSize?: number;
|
||||||
|
onFontSizeChange?: (value: number) => void;
|
||||||
|
mentionSoundEnabled?: boolean;
|
||||||
|
onMentionSoundEnabledChange?: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPanel({
|
||||||
|
twitchConnected,
|
||||||
|
twitchUser,
|
||||||
|
twitchProfileImage,
|
||||||
|
twitchUserName,
|
||||||
|
twitchChannels = [],
|
||||||
|
selectedTwitchChannel = '',
|
||||||
|
favoriteChannels = [],
|
||||||
|
onChannelChange,
|
||||||
|
onToggleFavorite,
|
||||||
|
onTwitchLogin,
|
||||||
|
onTwitchLogout,
|
||||||
|
youtubeConnected,
|
||||||
|
youtubeUser,
|
||||||
|
onYoutubeLogin,
|
||||||
|
onYoutubeLogout,
|
||||||
|
onManageWatchlist,
|
||||||
|
onManageYoutubeLinks,
|
||||||
|
embedDefaultOpen = true,
|
||||||
|
onEmbedDefaultOpenChange,
|
||||||
|
unifyChatEnabled = false,
|
||||||
|
onUnifyChatEnabledChange,
|
||||||
|
fontSize = 14,
|
||||||
|
onFontSizeChange,
|
||||||
|
mentionSoundEnabled = true,
|
||||||
|
onMentionSoundEnabledChange,
|
||||||
|
}: LoginPanelProps) {
|
||||||
|
const [manualChannel, setManualChannel] = React.useState('');
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
|
const [showEmbedConfig, setShowEmbedConfig] = React.useState(false);
|
||||||
|
|
||||||
|
const filteredChannels = React.useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return twitchChannels;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return twitchChannels.filter(
|
||||||
|
(channel) =>
|
||||||
|
channel.displayName.toLowerCase().includes(query) ||
|
||||||
|
channel.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [searchQuery, twitchChannels]);
|
||||||
|
|
||||||
|
const handleManualJoin = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (manualChannel.trim() && onChannelChange) {
|
||||||
|
onChannelChange(manualChannel.trim().toLowerCase());
|
||||||
|
setManualChannel('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="login-panel">
|
||||||
|
<div className="login-panel-header">
|
||||||
|
<h3>Connected Accounts</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEmbedConfig(true)}
|
||||||
|
className="icon-button small"
|
||||||
|
title="Configure global settings"
|
||||||
|
>
|
||||||
|
<FaCog />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="platform-login twitch">
|
||||||
|
<div className="platform-header">
|
||||||
|
<span className="platform-name">Twitch</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{twitchConnected && twitchUser ? (
|
||||||
|
<div className="connected-info">
|
||||||
|
<div className="user-profile-container">
|
||||||
|
<div
|
||||||
|
className="user-profile-link"
|
||||||
|
onClick={() => twitchUserName && onChannelChange?.(twitchUserName)}
|
||||||
|
title={`Switch to ${twitchUser}'s channel`}
|
||||||
|
>
|
||||||
|
{twitchProfileImage && (
|
||||||
|
<img src={twitchProfileImage} alt={twitchUser} className="profile-image" loading="eager" />
|
||||||
|
)}
|
||||||
|
<div className="user-details">
|
||||||
|
<p className="user-name">{twitchUser}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="user-actions">
|
||||||
|
{onManageWatchlist && (
|
||||||
|
<button
|
||||||
|
onClick={onManageWatchlist}
|
||||||
|
className="icon-button"
|
||||||
|
title="Manage Notification Streams"
|
||||||
|
>
|
||||||
|
<FaEye />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onManageYoutubeLinks && (
|
||||||
|
<button
|
||||||
|
onClick={onManageYoutubeLinks}
|
||||||
|
className="icon-button youtube-icon-btn"
|
||||||
|
title="Link YouTube channels"
|
||||||
|
>
|
||||||
|
<FaYoutube />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onTwitchLogout} className="logout-btn">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{twitchChannels && twitchChannels.length > 0 && (
|
||||||
|
<div className="channels-section">
|
||||||
|
<label className="section-label">
|
||||||
|
Channels ({filteredChannels.length})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search channels..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="channel-search-input"
|
||||||
|
/>
|
||||||
|
<div className="channel-list">
|
||||||
|
{filteredChannels.map((channel) => (
|
||||||
|
<div
|
||||||
|
key={channel.id}
|
||||||
|
className={`channel-item ${selectedTwitchChannel === channel.id ? 'selected' : ''}`}
|
||||||
|
onClick={() => onChannelChange?.(channel.id)}
|
||||||
|
title={channel.displayName}
|
||||||
|
>
|
||||||
|
{channel.profileImageUrl && (
|
||||||
|
<img
|
||||||
|
src={channel.profileImageUrl}
|
||||||
|
alt={channel.displayName}
|
||||||
|
className="channel-item-image"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="channel-item-name">
|
||||||
|
{channel.displayName}
|
||||||
|
</span>
|
||||||
|
{channel.viewerCount !== undefined && channel.viewerCount !== null && (
|
||||||
|
<span className="viewer-count">
|
||||||
|
<FaUsers size={12} /> {channel.viewerCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Don't also select the channel when toggling a favorite
|
||||||
|
onToggleFavorite?.(channel.id);
|
||||||
|
}}
|
||||||
|
className={`favorite-button ${favoriteChannels.includes(channel.id) ? 'active' : ''}`}
|
||||||
|
title={favoriteChannels.includes(channel.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
>
|
||||||
|
{favoriteChannels.includes(channel.id) ? <FaStar size={16} /> : <FaRegStar size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="manual-join-section">
|
||||||
|
<label className="section-label">
|
||||||
|
Or join manually
|
||||||
|
</label>
|
||||||
|
<form onSubmit={handleManualJoin} className="join-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manualChannel}
|
||||||
|
onChange={(e) => setManualChannel(e.target.value)}
|
||||||
|
placeholder="Enter channel name"
|
||||||
|
className="join-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!manualChannel.trim()}
|
||||||
|
className="logout-btn"
|
||||||
|
style={{
|
||||||
|
backgroundColor: manualChannel.trim() ? '#555555' : '#333333',
|
||||||
|
cursor: manualChannel.trim() ? 'pointer' : 'not-allowed',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={onTwitchLogin} className="login-btn twitch-btn">
|
||||||
|
Login with Twitch
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* YouTube login/logout */}
|
||||||
|
<div className="youtube-section">
|
||||||
|
{youtubeConnected ? (
|
||||||
|
<div className="youtube-info-row">
|
||||||
|
<div className="youtube-user-info">
|
||||||
|
<FaYoutube style={{ color: '#FF0000', fontSize: '18px', flexShrink: 0 }} />
|
||||||
|
<span className="youtube-user-name">{youtubeUser || 'YouTube'}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onYoutubeLogout} className="logout-btn small">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : onYoutubeLogin ? (
|
||||||
|
<button onClick={onYoutubeLogin} className="login-btn youtube-btn" style={{ width: '100%' }}>
|
||||||
|
Login with YouTube
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEmbedConfig && (
|
||||||
|
<EmbedConfigModal
|
||||||
|
embedDefaultOpen={embedDefaultOpen}
|
||||||
|
onEmbedDefaultOpenChange={(value) => {
|
||||||
|
if (onEmbedDefaultOpenChange) {
|
||||||
|
onEmbedDefaultOpenChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
unifyChatEnabled={unifyChatEnabled}
|
||||||
|
onUnifyChatEnabledChange={(value) => {
|
||||||
|
if (onUnifyChatEnabledChange) {
|
||||||
|
onUnifyChatEnabledChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fontSize={fontSize}
|
||||||
|
onFontSizeChange={(value) => {
|
||||||
|
if (onFontSizeChange) {
|
||||||
|
onFontSizeChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
mentionSoundEnabled={mentionSoundEnabled}
|
||||||
|
onMentionSoundEnabledChange={(value) => {
|
||||||
|
if (onMentionSoundEnabledChange) {
|
||||||
|
onMentionSoundEnabledChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClose={() => setShowEmbedConfig(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(LoginPanel);
|
||||||
518
src/components/MessageInput.tsx
Normal file
518
src/components/MessageInput.tsx
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
|
||||||
|
import { FaTimes, FaSmile, FaTwitch } from 'react-icons/fa';
|
||||||
|
import type { Platform, ChatMessage } from '../lib/types';
|
||||||
|
import { searchEmotes, getAllEmotesOrganized, type EmotesBySource } from '../lib/emotes';
|
||||||
|
import EmoteMenu from './EmoteMenu';
|
||||||
|
|
||||||
|
interface MessageInputProps {
|
||||||
|
onSendMessage: (message: string, platforms: Platform[], replyToId?: string) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
isConnected: { twitch: boolean };
|
||||||
|
replyingTo?: ChatMessage;
|
||||||
|
onCancelReply?: () => void;
|
||||||
|
chatMessages?: ChatMessage[];
|
||||||
|
currentUsername?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageInput({
|
||||||
|
onSendMessage,
|
||||||
|
isLoading,
|
||||||
|
isConnected,
|
||||||
|
replyingTo,
|
||||||
|
onCancelReply,
|
||||||
|
chatMessages = [],
|
||||||
|
currentUsername = '',
|
||||||
|
}: MessageInputProps) {
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [placeholder, setPlaceholder] = useState('Type your message... (Shift+Enter for new line)');
|
||||||
|
const [selectedPlatforms, setSelectedPlatforms] = useState<Platform[]>([
|
||||||
|
'twitch',
|
||||||
|
]);
|
||||||
|
const [emoteQuery, setEmoteQuery] = useState('');
|
||||||
|
const [emoteResults, setEmoteResults] = useState<Array<{ name: string; url: string; source: string }>>([]);
|
||||||
|
const [selectedEmoteIndex, setSelectedEmoteIndex] = useState(-1);
|
||||||
|
const [hasEmoteSelection, setHasEmoteSelection] = useState(false);
|
||||||
|
const [showEmoteMenu, setShowEmoteMenu] = useState(false);
|
||||||
|
const [showEmotePickerMenu, setShowEmotePickerMenu] = useState(false);
|
||||||
|
const [emotesOrganized, setEmotesOrganized] = useState<EmotesBySource>({
|
||||||
|
twitch: [],
|
||||||
|
'7tv': [],
|
||||||
|
bttv: [],
|
||||||
|
ffz: [],
|
||||||
|
});
|
||||||
|
const [usernameQuery, setUsernameQuery] = useState('');
|
||||||
|
const [usernameResults, setUsernameResults] = useState<string[]>([]);
|
||||||
|
const [selectedUsernameIndex, setSelectedUsernameIndex] = useState(-1);
|
||||||
|
const [showUsernameMenu, setShowUsernameMenu] = useState(false);
|
||||||
|
const [hasUsernameSelection, setHasUsernameSelection] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const emoteMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const shouldRefocusRef = useRef(false);
|
||||||
|
|
||||||
|
|
||||||
|
// Load organized emotes on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const organized = getAllEmotesOrganized();
|
||||||
|
setEmotesOrganized(organized);
|
||||||
|
|
||||||
|
// Listen for emotes-reloaded event
|
||||||
|
function handleEmotesReloaded() {
|
||||||
|
const updated = getAllEmotesOrganized();
|
||||||
|
setEmotesOrganized(updated);
|
||||||
|
}
|
||||||
|
window.addEventListener('emotes-reloaded', handleEmotesReloaded);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('emotes-reloaded', handleEmotesReloaded);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Refresh emotes whenever the emote picker menu opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (showEmotePickerMenu) {
|
||||||
|
const organized = getAllEmotesOrganized();
|
||||||
|
setEmotesOrganized(organized);
|
||||||
|
}
|
||||||
|
}, [showEmotePickerMenu]);
|
||||||
|
|
||||||
|
const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newMessage = e.target.value;
|
||||||
|
setMessage(newMessage);
|
||||||
|
|
||||||
|
// Get cursor position
|
||||||
|
const cursorPos = e.target.selectionStart;
|
||||||
|
const textBeforeCursor = newMessage.substring(0, cursorPos);
|
||||||
|
|
||||||
|
// Find the last word being typed (after space or at start)
|
||||||
|
const lastSpaceIndex = textBeforeCursor.lastIndexOf(' ');
|
||||||
|
const currentWord = textBeforeCursor.substring(lastSpaceIndex + 1);
|
||||||
|
|
||||||
|
// Check for @ mentions
|
||||||
|
if (currentWord.startsWith('@') && currentWord.length > 1) {
|
||||||
|
const query = currentWord.substring(1).toLowerCase();
|
||||||
|
const uniqueUsernames = new Set(chatMessages.map((msg) => msg.author));
|
||||||
|
const matching = Array.from(uniqueUsernames)
|
||||||
|
.filter((name) => name.toLowerCase().includes(query) && name.toLowerCase() !== currentUsername.toLowerCase())
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (matching.length > 0) {
|
||||||
|
setUsernameQuery(query);
|
||||||
|
setUsernameResults(matching);
|
||||||
|
setSelectedUsernameIndex(-1);
|
||||||
|
setHasUsernameSelection(false);
|
||||||
|
setShowUsernameMenu(true);
|
||||||
|
setShowEmoteMenu(false);
|
||||||
|
} else {
|
||||||
|
setShowUsernameMenu(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Show emote autocomplete if typing a word
|
||||||
|
else if (currentWord.length > 0 && !currentWord.startsWith('@')) {
|
||||||
|
const results = searchEmotes(currentWord);
|
||||||
|
if (results.length > 0) {
|
||||||
|
setEmoteQuery(currentWord);
|
||||||
|
setEmoteResults(results);
|
||||||
|
setSelectedEmoteIndex(-1);
|
||||||
|
setHasEmoteSelection(false);
|
||||||
|
setShowEmoteMenu(true);
|
||||||
|
setShowUsernameMenu(false);
|
||||||
|
} else {
|
||||||
|
setShowEmoteMenu(false);
|
||||||
|
setShowUsernameMenu(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setShowEmoteMenu(false);
|
||||||
|
setShowUsernameMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertEmote = (emoteName: string) => {
|
||||||
|
if (!textareaRef.current) return;
|
||||||
|
|
||||||
|
const cursorPos = textareaRef.current.selectionStart;
|
||||||
|
const textBeforeCursor = message.substring(0, cursorPos);
|
||||||
|
const textAfterCursor = message.substring(cursorPos);
|
||||||
|
|
||||||
|
// If message is empty, just insert the emote with a space after
|
||||||
|
if (!textBeforeCursor.trim()) {
|
||||||
|
const newMessage = emoteName + ' ' + textAfterCursor;
|
||||||
|
setMessage(newMessage);
|
||||||
|
setShowEmoteMenu(false);
|
||||||
|
setShowEmotePickerMenu(false);
|
||||||
|
setHasEmoteSelection(false);
|
||||||
|
|
||||||
|
// Set cursor after the inserted emote
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
const newCursorPos = (emoteName + ' ').length;
|
||||||
|
textareaRef.current.selectionStart = newCursorPos;
|
||||||
|
textareaRef.current.selectionEnd = newCursorPos;
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the start of the current word
|
||||||
|
const lastSpaceIndex = textBeforeCursor.lastIndexOf(' ');
|
||||||
|
const beforeWord = textBeforeCursor.substring(0, lastSpaceIndex + 1);
|
||||||
|
|
||||||
|
// Insert emote
|
||||||
|
const newMessage = beforeWord + emoteName + ' ' + textAfterCursor;
|
||||||
|
setMessage(newMessage);
|
||||||
|
setShowEmoteMenu(false);
|
||||||
|
setShowEmotePickerMenu(false);
|
||||||
|
setHasEmoteSelection(false);
|
||||||
|
|
||||||
|
// Set cursor after the inserted emote
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
const newCursorPos = (beforeWord + emoteName + ' ').length;
|
||||||
|
textareaRef.current.selectionStart = newCursorPos;
|
||||||
|
textareaRef.current.selectionEnd = newCursorPos;
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertUsername = (username: string) => {
|
||||||
|
if (!textareaRef.current) return;
|
||||||
|
|
||||||
|
const cursorPos = textareaRef.current.selectionStart;
|
||||||
|
const textBeforeCursor = message.substring(0, cursorPos);
|
||||||
|
const textAfterCursor = message.substring(cursorPos);
|
||||||
|
|
||||||
|
// Find the start of the @ mention
|
||||||
|
const lastSpaceIndex = textBeforeCursor.lastIndexOf(' ');
|
||||||
|
const beforeMention = textBeforeCursor.substring(0, lastSpaceIndex + 1);
|
||||||
|
|
||||||
|
// Insert username with @
|
||||||
|
const newMessage = beforeMention + '@' + username + ' ' + textAfterCursor;
|
||||||
|
setMessage(newMessage);
|
||||||
|
setShowUsernameMenu(false);
|
||||||
|
setHasUsernameSelection(false);
|
||||||
|
setSelectedUsernameIndex(-1);
|
||||||
|
|
||||||
|
// Set cursor after the inserted username
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
const newCursorPos = (beforeMention + '@' + username + ' ').length;
|
||||||
|
textareaRef.current.selectionStart = newCursorPos;
|
||||||
|
textareaRef.current.selectionEnd = newCursorPos;
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!message.trim() || selectedPlatforms.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
shouldRefocusRef.current = true;
|
||||||
|
await onSendMessage(message, selectedPlatforms, replyingTo?.id);
|
||||||
|
setMessage('');
|
||||||
|
setShowEmoteMenu(false);
|
||||||
|
setShowEmotePickerMenu(false);
|
||||||
|
if (onCancelReply) onCancelReply();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEmotePicker = () => {
|
||||||
|
setShowEmotePickerMenu(!showEmotePickerMenu);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
// If username menu is open, handle username navigation
|
||||||
|
if (showUsernameMenu) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedUsernameIndex((prev) =>
|
||||||
|
prev < usernameResults.length - 1 ? prev + 1 : prev
|
||||||
|
);
|
||||||
|
setHasUsernameSelection(true);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedUsernameIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
||||||
|
setHasUsernameSelection(true);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (hasUsernameSelection && selectedUsernameIndex >= 0 && usernameResults[selectedUsernameIndex]) {
|
||||||
|
e.preventDefault();
|
||||||
|
insertUsername(usernameResults[selectedUsernameIndex]);
|
||||||
|
} else {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowUsernameMenu(false);
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowUsernameMenu(false);
|
||||||
|
setSelectedUsernameIndex(-1);
|
||||||
|
setHasUsernameSelection(false);
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
if (hasUsernameSelection && selectedUsernameIndex >= 0 && usernameResults[selectedUsernameIndex]) {
|
||||||
|
e.preventDefault();
|
||||||
|
insertUsername(usernameResults[selectedUsernameIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If emote menu is open, handle emote navigation
|
||||||
|
if (showEmoteMenu) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedEmoteIndex((prev) =>
|
||||||
|
prev < emoteResults.length - 1 ? prev + 1 : prev
|
||||||
|
);
|
||||||
|
setHasEmoteSelection(true);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedEmoteIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
||||||
|
setHasEmoteSelection(true);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (!e.shiftKey && hasEmoteSelection && emoteResults[selectedEmoteIndex]) {
|
||||||
|
e.preventDefault();
|
||||||
|
insertEmote(emoteResults[selectedEmoteIndex].name);
|
||||||
|
} else if (!e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowEmoteMenu(false);
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowEmoteMenu(false);
|
||||||
|
setHasEmoteSelection(false);
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
if (hasEmoteSelection && emoteResults[selectedEmoteIndex]) {
|
||||||
|
e.preventDefault();
|
||||||
|
insertEmote(emoteResults[selectedEmoteIndex].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No menus open, just handle regular input
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
emoteMenuRef.current &&
|
||||||
|
!emoteMenuRef.current.contains(event.target as Node) &&
|
||||||
|
textareaRef.current &&
|
||||||
|
!textareaRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setShowEmoteMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Refocus input after send completes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && shouldRefocusRef.current) {
|
||||||
|
shouldRefocusRef.current = false;
|
||||||
|
setTimeout(() => textareaRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
}, [isLoading]);
|
||||||
|
// Scroll selected emote into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (showEmoteMenu && emoteMenuRef.current) {
|
||||||
|
const selectedElement = emoteMenuRef.current.children[selectedEmoteIndex] as HTMLElement;
|
||||||
|
if (selectedElement) {
|
||||||
|
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedEmoteIndex, showEmoteMenu]);
|
||||||
|
|
||||||
|
// Focus textarea when replying to a message
|
||||||
|
useEffect(() => {
|
||||||
|
if (replyingTo && textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [replyingTo]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-input">
|
||||||
|
{replyingTo && (
|
||||||
|
<div className="reply-indicator" style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#0f172a',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '0.9rem', color: '#94a3b8' }}>
|
||||||
|
<span style={{ fontWeight: 'bold' }}>Replying to @{replyingTo.author}</span>
|
||||||
|
<span style={{ marginLeft: '8px', fontStyle: 'italic' }}>
|
||||||
|
{replyingTo.content.substring(0, 50)}{replyingTo.content.length > 50 ? '...' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{onCancelReply && (
|
||||||
|
<button
|
||||||
|
onClick={onCancelReply}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#94a3b8',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0 8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
title="Cancel reply"
|
||||||
|
>
|
||||||
|
<FaTimes size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="input-group" style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleEmotePicker}
|
||||||
|
disabled={selectedPlatforms.length === 0 || isLoading}
|
||||||
|
className="emote-picker-button"
|
||||||
|
title="Browse emotes"
|
||||||
|
>
|
||||||
|
<FaSmile size={18} />
|
||||||
|
</button>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={message}
|
||||||
|
onChange={handleMessageChange}
|
||||||
|
onKeyDown={handleKeyPress}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={selectedPlatforms.length === 0 || isLoading}
|
||||||
|
className="message-textarea twitch"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={
|
||||||
|
!message.trim() ||
|
||||||
|
selectedPlatforms.length === 0 ||
|
||||||
|
isLoading
|
||||||
|
}
|
||||||
|
className="send-button"
|
||||||
|
>
|
||||||
|
{isLoading ? '...' : <FaTwitch size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showEmoteMenu && emoteResults.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={emoteMenuRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
maxHeight: '300px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: '#1e293b',
|
||||||
|
border: '1px solid #475569',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{emoteResults.map((emote, index) => (
|
||||||
|
<div
|
||||||
|
key={`${emote.source}-${emote.name}`}
|
||||||
|
onClick={() => insertEmote(emote.name)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: index === selectedEmoteIndex ? '#334155' : 'transparent',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setSelectedEmoteIndex(index)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={emote.url}
|
||||||
|
alt={emote.name}
|
||||||
|
style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
|
<span style={{ color: '#e2e8f0', fontWeight: '500' }}>{emote.name}</span>
|
||||||
|
<span style={{ color: '#94a3b8', fontSize: '0.75rem' }}>{emote.source}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showUsernameMenu && usernameResults.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
maxHeight: '250px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: '#1e293b',
|
||||||
|
border: '1px solid #475569',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{usernameResults.map((username, index) => (
|
||||||
|
<div
|
||||||
|
key={username}
|
||||||
|
onClick={() => insertUsername(username)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: hasUsernameSelection && index === selectedUsernameIndex ? '#334155' : 'transparent',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setSelectedUsernameIndex(index);
|
||||||
|
setHasUsernameSelection(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@{username}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmoteMenu
|
||||||
|
emotes={emotesOrganized}
|
||||||
|
onSelectEmote={insertEmote}
|
||||||
|
isOpen={showEmotePickerMenu}
|
||||||
|
onClose={() => setShowEmotePickerMenu(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedPlatforms.length === 0 && (
|
||||||
|
<div className="warning">
|
||||||
|
Select at least one platform to send messages
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(MessageInput);
|
||||||
34
src/components/Notification.tsx
Normal file
34
src/components/Notification.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface NotificationProps {
|
||||||
|
message: string;
|
||||||
|
type?: 'success' | 'error' | 'info';
|
||||||
|
duration?: number;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Notification({
|
||||||
|
message,
|
||||||
|
type = 'info',
|
||||||
|
duration = 3000,
|
||||||
|
onClose,
|
||||||
|
}: NotificationProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
onClose?.();
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`notification notification-${type}`}>
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
src/components/StreamEmbed.tsx
Normal file
257
src/components/StreamEmbed.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import HLS from 'hls.js';
|
||||||
|
|
||||||
|
interface StreamEmbedProps {
|
||||||
|
channelName: string;
|
||||||
|
playerType?: 'embed' | 'm3u8';
|
||||||
|
twitchConnected?: boolean;
|
||||||
|
onTwitchLogin?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StreamEmbed({ channelName, playerType = 'embed', twitchConnected, onTwitchLogin }: StreamEmbedProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const hlsRef = useRef<HLS | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [parentDomain, setParentDomain] = useState<string>('localhost');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/parent-domain')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setParentDomain(data.parentDomain))
|
||||||
|
.catch(() => setParentDomain('localhost'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// No longer forcing iframe remount on channel switch to help preserve session state.
|
||||||
|
// The browser will handle navigating the existing iframe to the new URL.
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playerType === 'embed' || !channelName) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadStream = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
// Clean up previous HLS instance
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/stream-playlist handles the full server-side proxy chain:
|
||||||
|
// Twitch GQL (token) → usher.ttvnw.net (master) → best variant →
|
||||||
|
// rewrite segment URLs to /api/stream-segment
|
||||||
|
// This avoids all browser CORS issues and IP-mismatch 403s.
|
||||||
|
const playlistUrl = `/api/stream-playlist?channel=${encodeURIComponent(channelName)}`;
|
||||||
|
|
||||||
|
if (HLS.isSupported()) {
|
||||||
|
const hls = new HLS({
|
||||||
|
debug: false,
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
// Re-fetch the live playlist frequently
|
||||||
|
liveSyncDurationCount: 3,
|
||||||
|
liveMaxLatencyDurationCount: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.on(HLS.Events.MANIFEST_PARSED, () => {
|
||||||
|
setLoading(false);
|
||||||
|
videoRef.current?.play().catch(() => {
|
||||||
|
// Auto-play failed — user will need to click play
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.on(HLS.Events.ERROR, (event, data) => {
|
||||||
|
if (data.fatal) {
|
||||||
|
setError(`Stream error: ${data.type}`);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.loadSource(playlistUrl);
|
||||||
|
hls.attachMedia(videoRef.current);
|
||||||
|
hlsRef.current = hls;
|
||||||
|
} else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
// Safari supports HLS natively
|
||||||
|
videoRef.current.src = playlistUrl;
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
setError('HLS not supported in this browser');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load stream:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load stream');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStream();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [channelName, playerType]);
|
||||||
|
|
||||||
|
|
||||||
|
if (!channelName) {
|
||||||
|
// If user is not logged in, show logo + Login with Twitch button
|
||||||
|
if (!twitchConnected) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
color: '#999',
|
||||||
|
fontSize: '14px',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src="/logo.png" alt="Mixchat" style={{ height: 115 }} />
|
||||||
|
<button
|
||||||
|
onClick={() => onTwitchLogin && onTwitchLogin()}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#6441a5',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login with Twitch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
color: '#999',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select a channel to view stream
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed mode
|
||||||
|
if (playerType === 'embed') {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
|
<iframe
|
||||||
|
src={`https://embed.twitch.tv/?channel=${channelName}&parent=${parentDomain}&layout=video&autoplay=true`}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 'none',
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
}}
|
||||||
|
frameBorder="0"
|
||||||
|
scrolling="no"
|
||||||
|
allowFullScreen={true}
|
||||||
|
title={`${channelName} Twitch Stream`}
|
||||||
|
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// M3U8 mode — error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
color: '#ff4444',
|
||||||
|
fontSize: '14px',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>⚠️ {error}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>The stream may be offline or unavailable</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// M3U8 mode — video player
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', width: '100%', backgroundColor: '#0a0a0a', position: 'relative' }}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
controls
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'block',
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
}}
|
||||||
|
title={`${channelName} Twitch Stream`}
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
color: '#999',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '30px',
|
||||||
|
height: '30px',
|
||||||
|
border: '3px solid #333',
|
||||||
|
borderTop: '3px solid #6366f1',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>Loading stream...</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
506
src/components/UnifiedChat.tsx
Normal file
506
src/components/UnifiedChat.tsx
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import React, { useEffect, useRef, useState, useCallback, memo } from 'react';
|
||||||
|
import { MdStars, MdAutorenew, MdCardGiftcard, MdReply, MdContentCopy } from 'react-icons/md';
|
||||||
|
import { GiHeartBeats } from 'react-icons/gi';
|
||||||
|
import { FaTwitch, FaYoutube } from 'react-icons/fa';
|
||||||
|
import type { ChatMessage } from '../lib/types';
|
||||||
|
import { parseMessageWithEmotes } from '../lib/emotes';
|
||||||
|
import '../styles/UnifiedChat.css';
|
||||||
|
|
||||||
|
// YouTube message shape — mirrors what YouTubeChat.tsx uses internally
|
||||||
|
interface MessagePart {
|
||||||
|
type: 'text' | 'emoji';
|
||||||
|
text?: string;
|
||||||
|
url?: string;
|
||||||
|
alt?: string;
|
||||||
|
emojiText?: string;
|
||||||
|
isCustomEmoji?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YTMessage {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
authorAvatar?: string;
|
||||||
|
badges?: Record<string, string>;
|
||||||
|
parts: MessagePart[];
|
||||||
|
timestamp: Date;
|
||||||
|
authorChannelId?: string;
|
||||||
|
superchat?: {
|
||||||
|
amount: string;
|
||||||
|
color: string;
|
||||||
|
sticker?: {
|
||||||
|
url: string;
|
||||||
|
alt: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single entry in the merged list, tagged with its platform source
|
||||||
|
type UnifiedEntry =
|
||||||
|
| { source: 'twitch'; msg: ChatMessage }
|
||||||
|
| { source: 'youtube'; msg: YTMessage };
|
||||||
|
|
||||||
|
function getKey(e: UnifiedEntry) {
|
||||||
|
return e.source + ':' + e.msg.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTime(e: UnifiedEntry) {
|
||||||
|
return e.msg.timestamp instanceof Date
|
||||||
|
? e.msg.timestamp.getTime()
|
||||||
|
: new Date(e.msg.timestamp).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generates a consistent color from a username, same algorithm used in YouTubeChat
|
||||||
|
function getColorFromName(name: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + name.charCodeAt(i);
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
const hue = Math.abs(hash) % 360;
|
||||||
|
return `hsl(${hue}, 70%, 55%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnifiedChatProps {
|
||||||
|
twitchMessages: ChatMessage[];
|
||||||
|
ytMessages: YTMessage[];
|
||||||
|
onReply?: (message: ChatMessage) => void;
|
||||||
|
onCopyMessage?: () => void;
|
||||||
|
currentUserDisplayName?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_MESSAGES = 300;
|
||||||
|
|
||||||
|
function UnifiedChat({
|
||||||
|
twitchMessages,
|
||||||
|
ytMessages,
|
||||||
|
onReply,
|
||||||
|
onCopyMessage,
|
||||||
|
currentUserDisplayName,
|
||||||
|
fontSize = 14,
|
||||||
|
}: UnifiedChatProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
const [newMessagesCount, setNewMessagesCount] = useState(0);
|
||||||
|
const prevCountRef = useRef(0);
|
||||||
|
|
||||||
|
// Merge Twitch and YouTube messages into one list sorted by time
|
||||||
|
const entries: UnifiedEntry[] = React.useMemo(() => {
|
||||||
|
const tw: UnifiedEntry[] = twitchMessages.map((m) => ({ source: 'twitch' as const, msg: m }));
|
||||||
|
const yt: UnifiedEntry[] = ytMessages.map((m) => ({ source: 'youtube' as const, msg: m }));
|
||||||
|
const merged = [...tw, ...yt].sort((a, b) => getTime(a) - getTime(b));
|
||||||
|
return merged.length > MAX_MESSAGES ? merged.slice(-MAX_MESSAGES) : merged;
|
||||||
|
}, [twitchMessages, ytMessages]);
|
||||||
|
|
||||||
|
// Scroll to the bottom whenever new messages arrive (only if already at bottom)
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current && isAtBottom) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [entries, isAtBottom]);
|
||||||
|
|
||||||
|
// Track whether the user has scrolled away from the bottom
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
||||||
|
setIsAtBottom(atBottom);
|
||||||
|
if (atBottom) {
|
||||||
|
setNewMessagesCount(0);
|
||||||
|
prevCountRef.current = entries.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('scroll', handleScroll);
|
||||||
|
return () => el.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [entries.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAtBottom) {
|
||||||
|
const diff = entries.length - prevCountRef.current;
|
||||||
|
if (diff > 0) setNewMessagesCount((p) => p + diff);
|
||||||
|
} else {
|
||||||
|
prevCountRef.current = entries.length;
|
||||||
|
}
|
||||||
|
}, [entries.length, isAtBottom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAtBottom) {
|
||||||
|
const diff = entries.length - prevCountRef.current;
|
||||||
|
if (diff > 0) setNewMessagesCount((p) => p + diff);
|
||||||
|
} else {
|
||||||
|
prevCountRef.current = entries.length;
|
||||||
|
}
|
||||||
|
}, [entries.length, isAtBottom]);
|
||||||
|
|
||||||
|
// Handle emote tooltip on hover
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEmoteHover = (e: Event) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
|
||||||
|
if (!target.classList.contains('chat-emote')) return;
|
||||||
|
// console.log('Emote hovered:', target.getAttribute('data-emote-name'));
|
||||||
|
|
||||||
|
if (e.type === 'mouseenter') {
|
||||||
|
const tooltipUrl = target.getAttribute('data-tooltip-url');
|
||||||
|
const emoteName = target.getAttribute('data-emote-name') || 'Emote';
|
||||||
|
|
||||||
|
if (tooltipUrl) {
|
||||||
|
const tooltip = document.createElement('div');
|
||||||
|
tooltip.className = 'chat-emote-tooltip';
|
||||||
|
tooltip.id = 'emote-tooltip';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = tooltipUrl;
|
||||||
|
img.alt = emoteName;
|
||||||
|
tooltip.appendChild(img);
|
||||||
|
|
||||||
|
const nameDiv = document.createElement('div');
|
||||||
|
nameDiv.className = 'chat-emote-tooltip-name';
|
||||||
|
nameDiv.textContent = emoteName;
|
||||||
|
tooltip.appendChild(nameDiv);
|
||||||
|
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
|
||||||
|
// Position tooltip near the mouse
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
tooltip.style.left = (rect.left + window.scrollX) + 'px';
|
||||||
|
tooltip.style.top = (rect.top + window.scrollY - tooltip.offsetHeight - 10) + 'px';
|
||||||
|
}
|
||||||
|
} else if (e.type === 'mouseleave') {
|
||||||
|
const tooltip = document.getElementById('emote-tooltip');
|
||||||
|
if (tooltip) {
|
||||||
|
tooltip.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const messagesContainer = containerRef.current;
|
||||||
|
if (messagesContainer) {
|
||||||
|
messagesContainer.addEventListener('mouseenter', handleEmoteHover, true);
|
||||||
|
messagesContainer.addEventListener('mouseleave', handleEmoteHover, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (messagesContainer) {
|
||||||
|
messagesContainer.removeEventListener('mouseenter', handleEmoteHover, true);
|
||||||
|
messagesContainer.removeEventListener('mouseleave', handleEmoteHover, true);
|
||||||
|
}
|
||||||
|
// Cleanup tooltip if it exists when component unmounts
|
||||||
|
const tooltip = document.getElementById('emote-tooltip');
|
||||||
|
if (tooltip) tooltip.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Small platform icon rendered before the username
|
||||||
|
const PlatformTag = ({ source }: { source: 'twitch' | 'youtube' }) => (
|
||||||
|
<span
|
||||||
|
title={source === 'twitch' ? "Twitch" : "YouTube"}
|
||||||
|
className={`platform-tag ${source}`}
|
||||||
|
>
|
||||||
|
{source === 'twitch' ? <FaTwitch size={12} /> : <FaYoutube size={12} />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helpers for Twitch-specific UI elements (event icons, badges)
|
||||||
|
const renderTwitchIcon = (msg: ChatMessage) => {
|
||||||
|
if (!msg.messageType) return null;
|
||||||
|
const iconConfig: Record<string, { title: string; color: string }> = {
|
||||||
|
cheer: { title: 'Cheer', color: '#FFB700' },
|
||||||
|
subscription: { title: 'Subscription', color: '#9147FF' },
|
||||||
|
resub: { title: 'Resub', color: '#9147FF' },
|
||||||
|
subgift: { title: 'Subgift', color: '#9147FF' },
|
||||||
|
};
|
||||||
|
const cfg = iconConfig[msg.messageType];
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
let icon;
|
||||||
|
switch (msg.messageType) {
|
||||||
|
case 'cheer': icon = <GiHeartBeats size={14} />; break;
|
||||||
|
case 'subscription': icon = <MdStars size={14} />; break;
|
||||||
|
case 'resub': icon = <MdAutorenew size={14} />; break;
|
||||||
|
case 'subgift': icon = <MdCardGiftcard size={14} />; break;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={cfg.title}
|
||||||
|
className="twitch-event-icon"
|
||||||
|
style={{ color: cfg.color, filter: `drop-shadow(0 0 2px ${cfg.color})` }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTwitchBadges = (msg: ChatMessage) => {
|
||||||
|
// Prefer detailed badge info if available (contains titles/descriptions)
|
||||||
|
if (msg.badgeDetails && Object.keys(msg.badgeDetails).length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
|
||||||
|
{Object.entries(msg.badgeDetails).map(([name, detail]) => (
|
||||||
|
<img
|
||||||
|
key={name}
|
||||||
|
src={detail.url}
|
||||||
|
alt={detail.title}
|
||||||
|
className="badge"
|
||||||
|
title={detail.title}
|
||||||
|
loading="eager"
|
||||||
|
style={{
|
||||||
|
width: '18px',
|
||||||
|
height: '18px',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for platform-agnostic or historical badges
|
||||||
|
if (!msg.badges || Object.keys(msg.badges).length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
|
||||||
|
{Object.entries(msg.badges).map(([name, url]) => {
|
||||||
|
if (!url) return null;
|
||||||
|
return (
|
||||||
|
<img key={name} src={url} alt={name} className="badge" title={name} loading="eager"
|
||||||
|
style={{ width: '18px', height: '18px', verticalAlign: 'middle' }} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderYTBadges = (msg: YTMessage) => {
|
||||||
|
if (!msg.badges || Object.keys(msg.badges).length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
|
||||||
|
{Object.entries(msg.badges).map(([name, url]) => (
|
||||||
|
<img key={name} src={url} alt={name} className="badge" title={name}
|
||||||
|
style={{ width: '18px', height: '18px', verticalAlign: 'middle' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render the scrollable message list with an optional "new messages" button
|
||||||
|
return (
|
||||||
|
<div className="unified-chat-wrapper">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="messages-container unified-messages-container"
|
||||||
|
style={{ '--chat-font-size': `${fontSize}px` } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className="no-messages">
|
||||||
|
<p>No messages yet. Start chatting!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.map((entry) => {
|
||||||
|
if (entry.source === 'twitch') {
|
||||||
|
const msg = entry.msg as ChatMessage;
|
||||||
|
const isMentioned = currentUserDisplayName && msg.content.includes(currentUserDisplayName);
|
||||||
|
const messageClasses = [
|
||||||
|
'message',
|
||||||
|
msg.messageType,
|
||||||
|
msg.isPreloaded ? 'preloaded' : '',
|
||||||
|
msg.isBot ? 'bot' : '',
|
||||||
|
((msg.mentionsUser || isMentioned) && !msg.isBot) ? 'mention' : ''
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={getKey(entry)} className={messageClasses}>
|
||||||
|
{msg.replyTo && (
|
||||||
|
<div className="reply-info">
|
||||||
|
<span>
|
||||||
|
response to{' '}
|
||||||
|
<span style={{ color: '#94a3b8' }}>@{msg.replyTo.author}</span>
|
||||||
|
{msg.replyTo.content && (
|
||||||
|
<>
|
||||||
|
{': '}
|
||||||
|
<span
|
||||||
|
className="reply-target-content"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: parseMessageWithEmotes(msg.replyTo.content)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="message-single-line">
|
||||||
|
<span className="timestamp">{msg.timestamp.toLocaleTimeString()}</span>
|
||||||
|
<PlatformTag source="twitch" />
|
||||||
|
{msg.authorAvatar && <img src={msg.authorAvatar} alt={msg.author} className="avatar" />}
|
||||||
|
|
||||||
|
{renderTwitchIcon(msg)}
|
||||||
|
{renderTwitchBadges(msg)}
|
||||||
|
<a
|
||||||
|
href={`https://twitch.tv/${msg.author}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`author author-link ${msg.isCurrentUser ? 'self' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: msg.isCurrentUser ? '#ffffff' : (msg.authorColor || '#e2e8f0'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.author}
|
||||||
|
</a>
|
||||||
|
<span className="message-separator">:</span>
|
||||||
|
<span
|
||||||
|
className="message-text"
|
||||||
|
dangerouslySetInnerHTML={{ __html: parseMessageWithEmotes(msg.content, msg.emotes) }}
|
||||||
|
/>
|
||||||
|
<div className="message-actions">
|
||||||
|
{onReply && (
|
||||||
|
<button onClick={() => onReply(msg)} className="action-button" title="Reply to this message" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<MdReply size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(msg.content).then(() => onCopyMessage?.()).catch(() => { });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="action-button" title="Copy message" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
|
||||||
|
>
|
||||||
|
<MdContentCopy size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube message
|
||||||
|
const msg = entry.msg as YTMessage;
|
||||||
|
if (msg.superchat) {
|
||||||
|
return (
|
||||||
|
<div key={getKey(entry)} className="yt-superchat" style={{ backgroundColor: msg.superchat.color }}>
|
||||||
|
<div className="yt-superchat-header">
|
||||||
|
{msg.authorAvatar && (
|
||||||
|
<img src={msg.authorAvatar} alt={msg.author} className="avatar" />
|
||||||
|
)}
|
||||||
|
<div className="yt-superchat-info">
|
||||||
|
<div className="yt-superchat-author">{msg.author}</div>
|
||||||
|
<div className="yt-superchat-amount">{msg.superchat.amount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(msg.parts.length > 0 || msg.superchat.sticker) && (
|
||||||
|
<div className="yt-superchat-body">
|
||||||
|
{msg.parts.map((part, i) =>
|
||||||
|
part.type === 'emoji' ? (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={part.url}
|
||||||
|
alt={part.alt || part.emojiText || '🙂'}
|
||||||
|
title={part.emojiText || part.alt}
|
||||||
|
className="chat-emote"
|
||||||
|
data-tooltip-url={part.url}
|
||||||
|
data-emote-name={part.alt || part.emojiText || 'Emoji'}
|
||||||
|
style={{
|
||||||
|
width: part.isCustomEmoji ? '28px' : '18px',
|
||||||
|
height: part.isCustomEmoji ? '28px' : '18px',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
margin: '0 1px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key={i}>{part.text}</React.Fragment>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{msg.superchat.sticker && (
|
||||||
|
<img
|
||||||
|
src={msg.superchat.sticker.url}
|
||||||
|
alt={msg.superchat.sticker.alt}
|
||||||
|
className="yt-superchat-sticker"
|
||||||
|
style={{ width: '72px', height: '72px', marginTop: '4px', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={getKey(entry)} className="message">
|
||||||
|
<div className="message-single-line">
|
||||||
|
<span className="timestamp">
|
||||||
|
{(msg.timestamp instanceof Date ? msg.timestamp : new Date(msg.timestamp))
|
||||||
|
.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
<PlatformTag source="youtube" />
|
||||||
|
{msg.authorAvatar && (
|
||||||
|
<img src={msg.authorAvatar} alt={msg.author} className="avatar" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderYTBadges(msg)}
|
||||||
|
<span className="author" style={{ color: getColorFromName(msg.author) }}>
|
||||||
|
{msg.author}
|
||||||
|
</span>
|
||||||
|
<span className="message-separator">:</span>
|
||||||
|
<span className="message-text">
|
||||||
|
{msg.parts.map((part, i) =>
|
||||||
|
part.type === 'emoji' ? (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={part.url}
|
||||||
|
alt={part.alt || part.emojiText || '🙂'}
|
||||||
|
title={part.emojiText || part.alt}
|
||||||
|
className="chat-emote"
|
||||||
|
data-tooltip-url={part.url}
|
||||||
|
data-emote-name={part.alt || part.emojiText || 'Emoji'}
|
||||||
|
style={{
|
||||||
|
width: part.isCustomEmoji ? '28px' : '18px',
|
||||||
|
height: part.isCustomEmoji ? '28px' : '18px',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
margin: '0 1px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key={i}>{part.text}</React.Fragment>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll-to-bottom button */}
|
||||||
|
{!isAtBottom && (
|
||||||
|
<button
|
||||||
|
className="go-to-bottom-button"
|
||||||
|
title="Go to latest messages"
|
||||||
|
onClick={() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||||
|
setIsAtBottom(true);
|
||||||
|
setNewMessagesCount(0);
|
||||||
|
prevCountRef.current = entries.length;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{newMessagesCount > 0 ? `New Messages (${newMessagesCount})` : 'New Messages'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(UnifiedChat);
|
||||||
154
src/components/WatchlistManager.tsx
Normal file
154
src/components/WatchlistManager.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { FaPlus, FaTrash, FaEye } from 'react-icons/fa';
|
||||||
|
import {
|
||||||
|
getWatchedStreamers,
|
||||||
|
addWatchedStreamer,
|
||||||
|
removeWatchedStreamer,
|
||||||
|
} from '../lib/streamMonitor';
|
||||||
|
import type { TwitchChannel } from '../components/AppContainer';
|
||||||
|
import '../styles/WatchlistManager.css';
|
||||||
|
|
||||||
|
interface WatchlistManagerProps {
|
||||||
|
availableChannels: TwitchChannel[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WatchlistManager({ availableChannels, onClose }: WatchlistManagerProps) {
|
||||||
|
const [watchedStreamers, setWatchedStreamers] = useState(getWatchedStreamers());
|
||||||
|
const [selectedChannelId, setSelectedChannelId] = useState<string>('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
|
||||||
|
const handleAddStreamer = () => {
|
||||||
|
if (!selectedChannelId) return;
|
||||||
|
|
||||||
|
const channel = availableChannels.find((c) => c.id === selectedChannelId);
|
||||||
|
if (channel) {
|
||||||
|
addWatchedStreamer({
|
||||||
|
id: channel.id,
|
||||||
|
name: channel.name,
|
||||||
|
displayName: channel.displayName,
|
||||||
|
profileImageUrl: channel.profileImageUrl,
|
||||||
|
});
|
||||||
|
setWatchedStreamers(getWatchedStreamers());
|
||||||
|
setSelectedChannelId('');
|
||||||
|
setSearchQuery('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveStreamer = (streamerId: string) => {
|
||||||
|
removeWatchedStreamer(streamerId);
|
||||||
|
setWatchedStreamers(getWatchedStreamers());
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAlreadyWatched = (channelId: string) =>
|
||||||
|
watchedStreamers.some((s) => s.id === channelId);
|
||||||
|
|
||||||
|
const unwatchedChannels = availableChannels.filter(
|
||||||
|
(c) => !isAlreadyWatched(c.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredChannels = searchQuery.trim() === ''
|
||||||
|
? unwatchedChannels
|
||||||
|
: unwatchedChannels.filter((c) =>
|
||||||
|
c.displayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="watchlist-manager-overlay" onClick={onClose}>
|
||||||
|
<div className="watchlist-manager" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>Manage Notification Streams</h2>
|
||||||
|
<p className="subtitle">Get notifications when these streamers go live</p>
|
||||||
|
|
||||||
|
<div className="watchlist-add-section">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search channels..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="channel-search"
|
||||||
|
/>
|
||||||
|
<div className="channel-list-dropdown">
|
||||||
|
{filteredChannels.length === 0 ? (
|
||||||
|
<div className="no-channels-message">
|
||||||
|
{searchQuery ? 'No channels match your search' : 'All followed channels are already being watched'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredChannels.map((channel) => (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
className="channel-list-item"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedChannelId(channel.id);
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channel.profileImageUrl && (
|
||||||
|
<img src={channel.profileImageUrl} alt="" className="channel-thumb" />
|
||||||
|
)}
|
||||||
|
<div className="channel-info">
|
||||||
|
<div className="channel-display-name">{channel.displayName}</div>
|
||||||
|
<div className="channel-username">@{channel.name}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedChannelId && (
|
||||||
|
<div className="selected-channel-display">
|
||||||
|
{unwatchedChannels.find((c) => c.id === selectedChannelId)?.displayName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="add-button"
|
||||||
|
onClick={handleAddStreamer}
|
||||||
|
disabled={!selectedChannelId}
|
||||||
|
>
|
||||||
|
<FaPlus /> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="watchlist-items">
|
||||||
|
{watchedStreamers.length === 0 ? (
|
||||||
|
<p className="empty-message">No streamers being watched yet</p>
|
||||||
|
) : (
|
||||||
|
watchedStreamers.map((streamer) => (
|
||||||
|
<div key={streamer.id} className="watchlist-item">
|
||||||
|
<div className="item-info">
|
||||||
|
{streamer.profileImageUrl && (
|
||||||
|
<img
|
||||||
|
src={streamer.profileImageUrl}
|
||||||
|
alt={streamer.displayName}
|
||||||
|
className="item-avatar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="item-details">
|
||||||
|
<div className="item-name">{streamer.displayName}</div>
|
||||||
|
<div className="item-username">@{streamer.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="remove-button"
|
||||||
|
onClick={() => handleRemoveStreamer(streamer.id)}
|
||||||
|
title="Remove from watchlist"
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="watchlist-footer">
|
||||||
|
<p className="info-text">
|
||||||
|
<FaEye style={{ marginRight: '6px' }} />
|
||||||
|
You'll be notified once per session when each streamer goes live
|
||||||
|
</p>
|
||||||
|
<button className="close-button" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
src/components/YouTubeChat.tsx
Normal file
305
src/components/YouTubeChat.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { PlatformSession } from '../lib/types';
|
||||||
|
import { AiOutlineLoading3Quarters } from 'react-icons/ai';
|
||||||
|
|
||||||
|
interface YouTubeEmote {
|
||||||
|
/** The emojiText code e.g. ":slightly_smiling_face:" or the alt text */
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
isCustom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YouTubeChatProps {
|
||||||
|
videoId: string;
|
||||||
|
accessToken: string;
|
||||||
|
session: PlatformSession;
|
||||||
|
/** Called whenever new emotes are discovered in the chat stream */
|
||||||
|
onEmotesDiscovered?: (emotes: YouTubeEmote[]) => void;
|
||||||
|
/** Called whenever the message list updates (for unified chat mode) */
|
||||||
|
onMessages?: (messages: ChatMessage[]) => void;
|
||||||
|
fontSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { YouTubeEmote };
|
||||||
|
|
||||||
|
interface MessagePart {
|
||||||
|
type: 'text' | 'emoji';
|
||||||
|
text?: string;
|
||||||
|
url?: string;
|
||||||
|
alt?: string;
|
||||||
|
emojiText?: string;
|
||||||
|
isCustomEmoji?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
authorAvatar?: string;
|
||||||
|
badges?: Record<string, string>;
|
||||||
|
parts: MessagePart[];
|
||||||
|
timestamp: Date;
|
||||||
|
authorChannelId?: string;
|
||||||
|
superchat?: {
|
||||||
|
amount: string;
|
||||||
|
color: string;
|
||||||
|
sticker?: {
|
||||||
|
url: string;
|
||||||
|
alt: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turns a username into a stable color so each author always looks the same
|
||||||
|
function getColorFromName(name: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + name.charCodeAt(i);
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
const hue = Math.abs(hash) % 360;
|
||||||
|
return `hsl(${hue}, 70%, 55%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function YouTubeChat({ videoId, accessToken, session, onEmotesDiscovered, onMessages, fontSize = 14 }: YouTubeChatProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const lastMessageCountRef = useRef(0);
|
||||||
|
// Remembers how many emotes we've already reported so we don't spam the parent
|
||||||
|
const lastEmoteCountRef = useRef(0);
|
||||||
|
|
||||||
|
// Keep messages fresh by polling our backend every couple of seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoId) return;
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const fetchChatMessages = async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/youtube-stream-chat?videoId=${encodeURIComponent(videoId)}`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
console.error('Chat API error:', errorData);
|
||||||
|
throw new Error(errorData.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (data.success && data.messages) {
|
||||||
|
const fetchedMessages = (data.messages as any[]).map((msg: any) => ({
|
||||||
|
id: msg.id,
|
||||||
|
author: msg.author,
|
||||||
|
authorAvatar: msg.authorAvatar,
|
||||||
|
badges: msg.badges,
|
||||||
|
parts: (msg.parts || []) as MessagePart[],
|
||||||
|
timestamp: new Date(msg.timestamp),
|
||||||
|
authorChannelId: msg.authorChannelId,
|
||||||
|
superchat: msg.superchat,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Deduplicate just in case the API sends anything twice
|
||||||
|
const uniqueMessagesMap = new Map();
|
||||||
|
fetchedMessages.forEach(m => uniqueMessagesMap.set(m.id, m));
|
||||||
|
const newMessages = Array.from(uniqueMessagesMap.values());
|
||||||
|
|
||||||
|
// Let the parent know about any newly spotted emotes (for autocomplete)
|
||||||
|
const currentEmoteCount = Array.isArray(data.emotes) ? data.emotes.length : 0;
|
||||||
|
if (onEmotesDiscovered && currentEmoteCount !== lastEmoteCountRef.current) {
|
||||||
|
lastEmoteCountRef.current = currentEmoteCount;
|
||||||
|
onEmotesDiscovered(
|
||||||
|
(data.emotes as any[]).map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
url: e.url,
|
||||||
|
isCustom: !!e.isCustomEmoji,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare the last message ID (not just count) to catch the edge case where
|
||||||
|
// one message rolls off while another comes in, keeping the count identical.
|
||||||
|
const lastNewId = newMessages.length > 0 ? newMessages[newMessages.length - 1].id : null;
|
||||||
|
const lastCurrentId = messages.length > 0 ? messages[messages.length - 1].id : null;
|
||||||
|
|
||||||
|
if (newMessages.length !== messages.length || lastNewId !== lastCurrentId) {
|
||||||
|
setMessages(newMessages);
|
||||||
|
lastMessageCountRef.current = newMessages.length;
|
||||||
|
|
||||||
|
// Set as connected once we get the first successful response
|
||||||
|
if (!isConnected) {
|
||||||
|
setIsConnected(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} else if (!isConnected) {
|
||||||
|
// Even an empty response counts as a successful connection
|
||||||
|
setIsConnected(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isMounted) {
|
||||||
|
console.error('Error fetching YouTube chat:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch messages');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kick off the first fetch right away, then repeat every 2 s
|
||||||
|
fetchChatMessages();
|
||||||
|
pollingIntervalRef.current = setInterval(fetchChatMessages, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [videoId, isConnected]);
|
||||||
|
|
||||||
|
// Keep the view pinned to the newest message
|
||||||
|
useEffect(() => {
|
||||||
|
if (messagesContainerRef.current) {
|
||||||
|
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Pass the current message list up so the parent can use it in unified chat
|
||||||
|
useEffect(() => {
|
||||||
|
if (onMessages) {
|
||||||
|
onMessages(messages);
|
||||||
|
}
|
||||||
|
}, [messages, onMessages]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="yt-chat-status">
|
||||||
|
<AiOutlineLoading3Quarters className="animate-spin" /> Connecting to YouTube chat...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="yt-chat-status yt-chat-error">
|
||||||
|
<div>⚠️ {error}</div>
|
||||||
|
<div className="yt-chat-error-sub">You can still send messages below</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="yt-chat-outer">
|
||||||
|
<div
|
||||||
|
ref={messagesContainerRef}
|
||||||
|
className="yt-chat-container"
|
||||||
|
style={{ '--chat-font-size': `${fontSize}px` } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{!isConnected && (
|
||||||
|
<div className="yt-chat-placeholder">
|
||||||
|
Connecting to YouTube chat...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isConnected && messages.length === 0 && (
|
||||||
|
<div className="yt-chat-placeholder">
|
||||||
|
Waiting for messages...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg) => {
|
||||||
|
if (msg.superchat) {
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="yt-superchat" style={{ backgroundColor: msg.superchat.color }}>
|
||||||
|
<div className="yt-superchat-header">
|
||||||
|
{msg.authorAvatar && (
|
||||||
|
<img src={msg.authorAvatar} alt={msg.author} className="avatar" />
|
||||||
|
)}
|
||||||
|
<div className="yt-superchat-info">
|
||||||
|
<div className="yt-superchat-author">{msg.author}</div>
|
||||||
|
<div className="yt-superchat-amount">{msg.superchat.amount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(msg.parts.length > 0 || msg.superchat.sticker) && (
|
||||||
|
<div className="yt-superchat-body">
|
||||||
|
{msg.parts.map((part, i) =>
|
||||||
|
part.type === 'emoji' ? (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={part.url}
|
||||||
|
alt={part.alt || part.emojiText || '🙂'}
|
||||||
|
title={part.emojiText || part.alt}
|
||||||
|
className={`yt-emoji ${part.isCustomEmoji ? 'custom' : 'standard'}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key={i}>{part.text}</React.Fragment>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{msg.superchat.sticker && (
|
||||||
|
<img
|
||||||
|
src={msg.superchat.sticker.url}
|
||||||
|
alt={msg.superchat.sticker.alt}
|
||||||
|
className="yt-superchat-sticker"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="message">
|
||||||
|
<div className="message-single-line">
|
||||||
|
<span className="timestamp">
|
||||||
|
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
{msg.authorAvatar && (
|
||||||
|
<img src={msg.authorAvatar} alt={msg.author} className="avatar" />
|
||||||
|
)}
|
||||||
|
{msg.badges && Object.keys(msg.badges).length > 0 && (
|
||||||
|
<div className="yt-message-badges">
|
||||||
|
{Object.entries(msg.badges).map(([name, url]) => (
|
||||||
|
<img
|
||||||
|
key={name}
|
||||||
|
src={url}
|
||||||
|
alt={name}
|
||||||
|
className="badge yt-badge"
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="author" style={{ color: getColorFromName(msg.author) }}>
|
||||||
|
{msg.author}
|
||||||
|
</span>
|
||||||
|
<span className="message-separator">:</span>
|
||||||
|
<span className="message-text">
|
||||||
|
{msg.parts.map((part, i) =>
|
||||||
|
part.type === 'emoji' ? (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={part.url}
|
||||||
|
alt={part.alt || part.emojiText || '🙂'}
|
||||||
|
title={part.emojiText || part.alt}
|
||||||
|
className={`yt-emoji ${part.isCustomEmoji ? 'custom' : 'standard'}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key={i}>{part.text}</React.Fragment>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
src/components/YouTubeLinker.tsx
Normal file
202
src/components/YouTubeLinker.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { FaPlus, FaTrash, FaYoutube } from 'react-icons/fa';
|
||||||
|
import {
|
||||||
|
getYoutubeLinks,
|
||||||
|
addYoutubeLink,
|
||||||
|
removeYoutubeLink,
|
||||||
|
} from '../lib/youtubeLinks';
|
||||||
|
import type { YouTubeLink } from '../lib/youtubeLinks';
|
||||||
|
import type { TwitchChannel } from '../components/AppContainer';
|
||||||
|
import '../styles/YouTubeLinker.css';
|
||||||
|
|
||||||
|
interface YouTubeLinkerProps {
|
||||||
|
availableChannels: TwitchChannel[];
|
||||||
|
onClose: () => void;
|
||||||
|
onLinksChanged?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function YouTubeLinker({ availableChannels, onClose, onLinksChanged }: YouTubeLinkerProps) {
|
||||||
|
const [youtubeLinks, setYoutubeLinks] = useState<YouTubeLink[]>(getYoutubeLinks());
|
||||||
|
const [selectedChannelId, setSelectedChannelId] = useState<string>('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [youtubeUrl, setYoutubeUrl] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const handleAddLink = () => {
|
||||||
|
if (!selectedChannelId || !youtubeUrl.trim()) return;
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
const trimmed = youtubeUrl.trim();
|
||||||
|
if (!trimmed.startsWith('https://www.youtube.com/') && !trimmed.startsWith('https://youtube.com/') && !trimmed.startsWith('http://www.youtube.com/') && !trimmed.startsWith('http://youtube.com/')) {
|
||||||
|
setError('Please enter a valid YouTube channel URL (e.g. https://www.youtube.com/@channelname)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = availableChannels.find((c) => c.id === selectedChannelId);
|
||||||
|
if (channel) {
|
||||||
|
addYoutubeLink({
|
||||||
|
twitchChannelId: channel.id,
|
||||||
|
twitchDisplayName: channel.displayName,
|
||||||
|
youtubeUrl: trimmed,
|
||||||
|
});
|
||||||
|
setYoutubeLinks(getYoutubeLinks());
|
||||||
|
setSelectedChannelId('');
|
||||||
|
setSearchQuery('');
|
||||||
|
setYoutubeUrl('');
|
||||||
|
setError('');
|
||||||
|
onLinksChanged?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLink = (twitchChannelId: string) => {
|
||||||
|
removeYoutubeLink(twitchChannelId);
|
||||||
|
setYoutubeLinks(getYoutubeLinks());
|
||||||
|
onLinksChanged?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAlreadyLinked = (channelId: string) =>
|
||||||
|
youtubeLinks.some((l) => l.twitchChannelId === channelId);
|
||||||
|
|
||||||
|
const unlinkedChannels = availableChannels.filter(
|
||||||
|
(c) => !isAlreadyLinked(c.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredChannels = searchQuery.trim() === ''
|
||||||
|
? unlinkedChannels
|
||||||
|
: unlinkedChannels.filter((c) =>
|
||||||
|
c.displayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ytlinker-overlay" onClick={onClose}>
|
||||||
|
<div className="ytlinker-manager" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>
|
||||||
|
<FaYoutube style={{ color: '#FF0000', marginRight: '8px', verticalAlign: 'middle' }} />
|
||||||
|
Link YouTube Channels
|
||||||
|
</h2>
|
||||||
|
<p className="ytlinker-subtitle">Link a YouTube channel to a followed streamer to see their YouTube live chat</p>
|
||||||
|
|
||||||
|
<div className="ytlinker-add-section">
|
||||||
|
<div className="ytlinker-inputs">
|
||||||
|
<div className="ytlinker-channel-select">
|
||||||
|
<label className="ytlinker-label">Twitch Channel</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search followed channels..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setSelectedChannelId('');
|
||||||
|
}}
|
||||||
|
className="ytlinker-search"
|
||||||
|
/>
|
||||||
|
<div className="ytlinker-dropdown">
|
||||||
|
{filteredChannels.length === 0 ? (
|
||||||
|
<div className="ytlinker-no-channels">
|
||||||
|
{searchQuery ? 'No channels match your search' : 'All followed channels already have a YouTube link'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredChannels.map((channel) => (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
className="ytlinker-channel-item"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedChannelId(channel.id);
|
||||||
|
setSearchQuery(channel.displayName);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channel.profileImageUrl && (
|
||||||
|
<img src={channel.profileImageUrl} alt="" className="item-avatar" />
|
||||||
|
)}
|
||||||
|
<div className="ytlinker-channel-info">
|
||||||
|
<div className="ytlinker-channel-display-name">{channel.displayName}</div>
|
||||||
|
<div className="ytlinker-channel-username">@{channel.name}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedChannelId && (
|
||||||
|
<div className="ytlinker-selected-display">
|
||||||
|
✓ {unlinkedChannels.find((c) => c.id === selectedChannelId)?.displayName || searchQuery}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ytlinker-url-input">
|
||||||
|
<label className="ytlinker-label">YouTube Channel URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://www.youtube.com/@channelname"
|
||||||
|
value={youtubeUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setYoutubeUrl(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
className="ytlinker-search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="ytlinker-error">{error}</div>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="ytlinker-add-button"
|
||||||
|
onClick={handleAddLink}
|
||||||
|
disabled={!selectedChannelId || !youtubeUrl.trim()}
|
||||||
|
>
|
||||||
|
<FaPlus /> Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ytlinker-items">
|
||||||
|
{youtubeLinks.length === 0 ? (
|
||||||
|
<p className="ytlinker-empty">No YouTube channels linked yet</p>
|
||||||
|
) : (
|
||||||
|
youtubeLinks.map((link) => {
|
||||||
|
const channel = availableChannels.find((c) => c.id === link.twitchChannelId);
|
||||||
|
return (
|
||||||
|
<div key={link.twitchChannelId} className="ytlinker-item">
|
||||||
|
<div className="ytlinker-item-info">
|
||||||
|
{channel?.profileImageUrl && (
|
||||||
|
<img src={channel.profileImageUrl} alt="" className="item-avatar" />
|
||||||
|
)}
|
||||||
|
<div className="ytlinker-item-details">
|
||||||
|
<div className="ytlinker-item-name">{link.twitchDisplayName}</div>
|
||||||
|
<a
|
||||||
|
href={link.youtubeUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ytlinker-item-url"
|
||||||
|
>
|
||||||
|
{link.youtubeUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="ytlinker-remove-button"
|
||||||
|
onClick={() => handleRemoveLink(link.twitchChannelId)}
|
||||||
|
title="Remove YouTube link"
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ytlinker-footer">
|
||||||
|
<p className="ytlinker-info-text">
|
||||||
|
<FaYoutube style={{ marginRight: '6px', color: '#FF0000' }} />
|
||||||
|
When viewing a linked channel, YouTube live chat will appear above the Twitch chat
|
||||||
|
</p>
|
||||||
|
<button className="ytlinker-close-button" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/env.d.ts
vendored
Normal file
1
src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
90
src/hooks/useStreamMonitor.ts
Normal file
90
src/hooks/useStreamMonitor.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
checkLiveStreamers,
|
||||||
|
getWatchedStreamers,
|
||||||
|
markStreamerNotified,
|
||||||
|
markStreamerOffline,
|
||||||
|
getNotifiedStreamers,
|
||||||
|
} from '../lib/streamMonitor';
|
||||||
|
import type { LiveStreamer } from '../lib/streamMonitor';
|
||||||
|
import type { PlatformSession } from '../lib/types';
|
||||||
|
|
||||||
|
interface UseStreamMonitorOptions {
|
||||||
|
session: PlatformSession | undefined;
|
||||||
|
clientId: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
checkInterval?: number; // in milliseconds, default 60000 (1 minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStreamMonitor({
|
||||||
|
session,
|
||||||
|
clientId,
|
||||||
|
enabled = true,
|
||||||
|
checkInterval = 60000,
|
||||||
|
}: UseStreamMonitorOptions) {
|
||||||
|
const [liveStreamers, setLiveStreamers] = useState<LiveStreamer[]>([]);
|
||||||
|
const [newLiveStreamer, setNewLiveStreamer] = useState<LiveStreamer | null>(null);
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
|
||||||
|
const checkStreamers = useCallback(async () => {
|
||||||
|
if (!session || !enabled) return;
|
||||||
|
|
||||||
|
setIsChecking(true);
|
||||||
|
try {
|
||||||
|
const watchedStreamers = getWatchedStreamers();
|
||||||
|
const nowLive = await checkLiveStreamers(watchedStreamers, session.accessToken, clientId);
|
||||||
|
|
||||||
|
const notifiedStreamers = getNotifiedStreamers();
|
||||||
|
|
||||||
|
// Check for newly live streamers
|
||||||
|
const newlyLive = nowLive.filter((streamer) => !notifiedStreamers.has(streamer.id));
|
||||||
|
|
||||||
|
if (newlyLive.length > 0) {
|
||||||
|
// Notify about the first newly live streamer
|
||||||
|
const firstNewStreamer = newlyLive[0];
|
||||||
|
setNewLiveStreamer(firstNewStreamer);
|
||||||
|
markStreamerNotified(firstNewStreamer.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for streamers that went offline
|
||||||
|
for (const streamerId of notifiedStreamers) {
|
||||||
|
if (!nowLive.find((s) => s.id === streamerId)) {
|
||||||
|
markStreamerOffline(streamerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLiveStreamers(nowLive);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error monitoring streams:', error);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
}, [session, clientId, enabled]);
|
||||||
|
|
||||||
|
// Check on mount and then periodically
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !session) {
|
||||||
|
setNewLiveStreamer(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check immediately
|
||||||
|
checkStreamers();
|
||||||
|
|
||||||
|
// Set up interval
|
||||||
|
const interval = setInterval(checkStreamers, checkInterval);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [checkStreamers, enabled, session, checkInterval]);
|
||||||
|
|
||||||
|
const clearNewNotification = useCallback(() => {
|
||||||
|
setNewLiveStreamer(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
liveStreamers,
|
||||||
|
newLiveStreamer,
|
||||||
|
isChecking,
|
||||||
|
clearNewNotification,
|
||||||
|
};
|
||||||
|
}
|
||||||
158
src/hooks/useTokenRefresh.ts
Normal file
158
src/hooks/useTokenRefresh.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import type { PlatformSession } from '../lib/types';
|
||||||
|
|
||||||
|
interface TokenRefreshOptions {
|
||||||
|
warningThreshold?: number; // ms before expiry to start refresh (default: 5 min)
|
||||||
|
checkInterval?: number; // ms between checks (default: 1 min)
|
||||||
|
maxRetries?: number; // max refresh attempts (default: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_WARNING_THRESHOLD = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const DEFAULT_CHECK_INTERVAL = 60 * 1000; // 1 minute
|
||||||
|
const DEFAULT_MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for automatic Twitch token refresh with retry logic
|
||||||
|
* Monitors token expiration and proactively refreshes before expiry
|
||||||
|
*/
|
||||||
|
export const useTokenRefresh = (
|
||||||
|
session: PlatformSession | null,
|
||||||
|
onSessionUpdate: (session: PlatformSession) => void,
|
||||||
|
onRefreshError: (error: Error) => void,
|
||||||
|
options: TokenRefreshOptions = {}
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
warningThreshold = DEFAULT_WARNING_THRESHOLD,
|
||||||
|
checkInterval = DEFAULT_CHECK_INTERVAL,
|
||||||
|
maxRetries = DEFAULT_MAX_RETRIES,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout>();
|
||||||
|
const retryCountRef = useRef(0);
|
||||||
|
const isRefreshingRef = useRef(false);
|
||||||
|
|
||||||
|
const refreshToken = useCallback(async () => {
|
||||||
|
// We need at least a session to refresh. The actual refresh token is read
|
||||||
|
// from the httpOnly cookie server-side, so session.refreshToken is optional.
|
||||||
|
if (!session || isRefreshingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshingRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/refresh-token', {
|
||||||
|
method: 'POST',
|
||||||
|
// Ensure httpOnly auth cookies are sent with the request so the
|
||||||
|
// server-side refresh endpoint can read the refresh token.
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
// Only send the platform — the server reads the refresh token
|
||||||
|
// from the httpOnly cookie, not from client-supplied data.
|
||||||
|
body: JSON.stringify({
|
||||||
|
platform: session.platform,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTokenData = await response.json();
|
||||||
|
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const updatedSession = {
|
||||||
|
...session,
|
||||||
|
accessToken: newTokenData.accessToken,
|
||||||
|
refreshToken: newTokenData.refreshToken || session.refreshToken,
|
||||||
|
expiresAt: now + ((newTokenData.expiresIn || 3600) * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
onSessionUpdate(updatedSession);
|
||||||
|
retryCountRef.current = 0; // Reset retry count on success
|
||||||
|
|
||||||
|
console.log('✓ Token refreshed successfully', {
|
||||||
|
expiresIn: newTokenData.expiresIn,
|
||||||
|
expiresAt: new Date(updatedSession.expiresAt).toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||||
|
console.error('✗ Token refresh failed:', err.message);
|
||||||
|
|
||||||
|
retryCountRef.current += 1;
|
||||||
|
|
||||||
|
if (retryCountRef.current >= maxRetries) {
|
||||||
|
console.error(`Token refresh failed after ${maxRetries} retries. Logging out.`);
|
||||||
|
onRefreshError(err);
|
||||||
|
retryCountRef.current = 0;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isRefreshingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [session, onSessionUpdate, onRefreshError, maxRetries]);
|
||||||
|
|
||||||
|
const checkAndRefresh = useCallback(() => {
|
||||||
|
// Require at least an expiresAt to know we have a real session.
|
||||||
|
// We do NOT require session.refreshToken here because the server reads it
|
||||||
|
// from the httpOnly cookie — the client-side refreshToken is optional.
|
||||||
|
if (!session?.expiresAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const timeUntilExpiry = session.expiresAt - now;
|
||||||
|
|
||||||
|
// Also refresh immediately when the accessToken is absent (e.g. on page
|
||||||
|
// reload — we no longer persist it to localStorage so it starts undefined).
|
||||||
|
const tokenMissing = !session.accessToken;
|
||||||
|
|
||||||
|
if (tokenMissing || timeUntilExpiry < warningThreshold) {
|
||||||
|
if (tokenMissing) {
|
||||||
|
console.log('Access token missing (page reload), refreshing immediately...');
|
||||||
|
} else {
|
||||||
|
console.log(`Token expiring in ${Math.round(timeUntilExpiry / 1000)}s, refreshing...`);
|
||||||
|
}
|
||||||
|
refreshToken();
|
||||||
|
}
|
||||||
|
}, [session, warningThreshold, refreshToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// We need at least a session with expiresAt to schedule refreshes.
|
||||||
|
// The actual refresh token lives in the httpOnly cookie on the server.
|
||||||
|
if (!session?.expiresAt) {
|
||||||
|
// Clear interval if no session
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = undefined;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkAndRefresh();
|
||||||
|
|
||||||
|
// Set up periodic check
|
||||||
|
intervalRef.current = setInterval(checkAndRefresh, checkInterval);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [session, checkInterval, checkAndRefresh]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
refreshToken,
|
||||||
|
isRefreshing: isRefreshingRef.current,
|
||||||
|
retryCount: retryCountRef.current,
|
||||||
|
};
|
||||||
|
};
|
||||||
382
src/hooks/useTwitchChat.ts
Normal file
382
src/hooks/useTwitchChat.ts
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { StaticAuthProvider } from '@twurple/auth';
|
||||||
|
import { ChatClient } from '@twurple/chat';
|
||||||
|
import { getBadge, getBadgeDetail } from '../lib/badges';
|
||||||
|
import type { ChatMessage } from '../lib/types';
|
||||||
|
|
||||||
|
// Cache for user info - limit to 500 entries for memory efficiency
|
||||||
|
const userInfoCache = new Map<string, { displayName: string; profileImageUrl: string | null }>();
|
||||||
|
const MAX_CACHE_SIZE = 500;
|
||||||
|
const NOTIFICATION_AUDIO_VOLUME = 0.3;
|
||||||
|
|
||||||
|
function setUserInfoCache(userId: string, info: { displayName: string; profileImageUrl: string | null }) {
|
||||||
|
// Limit cache size to prevent memory leaks
|
||||||
|
if (userInfoCache.size >= MAX_CACHE_SIZE) {
|
||||||
|
const firstKey = userInfoCache.keys().next().value;
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
userInfoCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userInfoCache.set(userId, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async function to get user info
|
||||||
|
async function getUserInfo(userId: string, accessToken: string) {
|
||||||
|
if (userInfoCache.has(userId)) {
|
||||||
|
return userInfoCache.get(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/user-info?userId=${userId}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUserInfoCache(userId, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user info:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTwitchChat(
|
||||||
|
channelName: string,
|
||||||
|
accessToken: string,
|
||||||
|
username: string,
|
||||||
|
onMessage: (message: ChatMessage) => void,
|
||||||
|
enabled: boolean = true,
|
||||||
|
mentionSoundEnabled: boolean = true
|
||||||
|
) {
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [client, setClient] = useState<ChatClient | null>(null);
|
||||||
|
|
||||||
|
// Retrieve Client ID from env; it's required for twurple's AuthProvider
|
||||||
|
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (!channelName || !accessToken || !username || !clientId) {
|
||||||
|
if (enabled && !clientId) {
|
||||||
|
console.error('Twitch Client ID is missing (PUBLIC_TWITCH_CLIENT_ID env var)');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
// Use StaticAuthProvider for client-side chat
|
||||||
|
const authProvider = new StaticAuthProvider(clientId, accessToken);
|
||||||
|
|
||||||
|
// Initialize the Twurple ChatClient
|
||||||
|
const newClient = new ChatClient({
|
||||||
|
authProvider,
|
||||||
|
channels: [channelName],
|
||||||
|
// reconnect handled by default in twurple
|
||||||
|
});
|
||||||
|
|
||||||
|
newClient.onConnect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
console.log(`Connected to ${channelName}`);
|
||||||
|
setIsConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
newClient.onDisconnect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
console.log('Disconnected from Twitch chat');
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
newClient.onMessage((channel, user, message, msg) => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
const replyInfo = msg.parentMessageId ? {
|
||||||
|
id: msg.parentMessageId,
|
||||||
|
author: msg.parentMessageUserDisplayName || msg.parentMessageUserName || '',
|
||||||
|
content: msg.parentMessageText || '',
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
|
const mentionsUser = (
|
||||||
|
message.toLowerCase().includes(`@${username.toLowerCase()}`) ||
|
||||||
|
(replyInfo?.author.toLowerCase() === username.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map Twitch emotes - Twurple provides emote data in msg.emotes
|
||||||
|
const twitchEmotes: Record<string, string[]> = {};
|
||||||
|
msg.emoteOffsets.forEach((offsets, emoteId) => {
|
||||||
|
twitchEmotes[emoteId] = offsets;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBot = msg.userInfo.badges.has('bot');
|
||||||
|
|
||||||
|
const badgeImages: Record<string, string> = {};
|
||||||
|
const badgeDetails: Record<string, { url: string; title: string }> = {};
|
||||||
|
msg.userInfo.badges.forEach((version, badgeName) => {
|
||||||
|
const detail = getBadgeDetail(badgeName, version);
|
||||||
|
if (detail) {
|
||||||
|
badgeImages[badgeName] = detail.url;
|
||||||
|
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cachedUserInfo = msg.userInfo.userId ? userInfoCache.get(msg.userInfo.userId) : undefined;
|
||||||
|
|
||||||
|
const chatMessage: ChatMessage = {
|
||||||
|
id: msg.id,
|
||||||
|
platform: 'twitch',
|
||||||
|
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
|
||||||
|
content: msg.isCheer ? `${message} [${msg.bits} bits]` : message,
|
||||||
|
timestamp: msg.date,
|
||||||
|
userId: msg.userInfo.userId,
|
||||||
|
authorColor: msg.userInfo.color || '#FFFFFF',
|
||||||
|
authorAvatar: cachedUserInfo?.profileImageUrl || undefined,
|
||||||
|
replyTo: replyInfo,
|
||||||
|
mentionsUser,
|
||||||
|
emotes: twitchEmotes,
|
||||||
|
isBot,
|
||||||
|
badges: badgeImages,
|
||||||
|
badgeDetails,
|
||||||
|
isCurrentUser: msg.userInfo.userName.toLowerCase() === username.toLowerCase(),
|
||||||
|
messageType: msg.isRedemption ? 'redemption' : (msg.isCheer ? 'cheer' : 'chat'),
|
||||||
|
rewardId: msg.isRedemption ? (msg.rewardId || undefined) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cachedUserInfo && msg.userInfo.userId) {
|
||||||
|
getUserInfo(msg.userInfo.userId, accessToken).then((userInfo) => {
|
||||||
|
if (userInfo?.profileImageUrl) {
|
||||||
|
onMessage({
|
||||||
|
...chatMessage,
|
||||||
|
authorAvatar: userInfo.profileImageUrl,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onMessage(chatMessage);
|
||||||
|
}).catch(() => {
|
||||||
|
onMessage(chatMessage);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onMessage(chatMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mentionsUser && mentionSoundEnabled) {
|
||||||
|
try {
|
||||||
|
const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGG2S36+OZSgwOUKXh8bllHgU2jtXywH0pBSl+zPLaizsIGGS56+idTgwNU6rm8bllHAU5kdXx');
|
||||||
|
audio.volume = NOTIFICATION_AUDIO_VOLUME;
|
||||||
|
audio.play().catch(() => { });
|
||||||
|
} catch (e) { }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
newClient.onAction((channel, user, message, msg) => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
const badgeImages: Record<string, string> = {};
|
||||||
|
const badgeDetails: Record<string, { url: string; title: string }> = {};
|
||||||
|
msg.userInfo.badges.forEach((version, badgeName) => {
|
||||||
|
const detail = getBadgeDetail(badgeName, version);
|
||||||
|
if (detail) {
|
||||||
|
badgeImages[badgeName] = detail.url;
|
||||||
|
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cachedUserInfo = msg.userInfo.userId ? userInfoCache.get(msg.userInfo.userId) : undefined;
|
||||||
|
|
||||||
|
const chatMessage: ChatMessage = {
|
||||||
|
id: msg.id,
|
||||||
|
platform: 'twitch',
|
||||||
|
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
|
||||||
|
content: message,
|
||||||
|
timestamp: msg.date,
|
||||||
|
userId: msg.userInfo.userId,
|
||||||
|
authorColor: msg.userInfo.color || '#FFFFFF',
|
||||||
|
authorAvatar: cachedUserInfo?.profileImageUrl || undefined,
|
||||||
|
mentionsUser: false,
|
||||||
|
emotes: {},
|
||||||
|
isBot: msg.userInfo.badges.has('bot'),
|
||||||
|
badges: badgeImages,
|
||||||
|
badgeDetails,
|
||||||
|
isCurrentUser: msg.userInfo.userName.toLowerCase() === username.toLowerCase(),
|
||||||
|
messageType: 'chat',
|
||||||
|
};
|
||||||
|
|
||||||
|
onMessage(chatMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
newClient.onSub((channel, user, subInfo, msg) => {
|
||||||
|
if (!active) return;
|
||||||
|
const tier = subInfo.plan === 'Prime' ? 'Prime' : subInfo.plan === '3000' ? '3' : subInfo.plan === '2000' ? '2' : '1';
|
||||||
|
const subMessage = `subscribed with ${subInfo.plan === 'Prime' ? 'Prime Gaming' : `a Tier ${tier} subscription`}${subInfo.message ? ` - ${subInfo.message}` : ''}`;
|
||||||
|
|
||||||
|
const badgeImages: Record<string, string> = {};
|
||||||
|
const badgeDetails: Record<string, { url: string; title: string }> = {};
|
||||||
|
msg.userInfo.badges.forEach((version, badgeName) => {
|
||||||
|
const detail = getBadgeDetail(badgeName, version);
|
||||||
|
if (detail) {
|
||||||
|
badgeImages[badgeName] = detail.url;
|
||||||
|
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatMessage: ChatMessage = {
|
||||||
|
id: msg.id,
|
||||||
|
platform: 'twitch',
|
||||||
|
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
|
||||||
|
content: subMessage,
|
||||||
|
timestamp: msg.date,
|
||||||
|
userId: msg.userInfo.userId,
|
||||||
|
authorColor: msg.userInfo.color || '#FFFFFF',
|
||||||
|
mentionsUser: false,
|
||||||
|
emotes: {},
|
||||||
|
isBot: false,
|
||||||
|
badges: badgeImages,
|
||||||
|
badgeDetails,
|
||||||
|
messageType: 'subscription',
|
||||||
|
};
|
||||||
|
|
||||||
|
onMessage(chatMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
newClient.onResub((channel, user, subInfo, msg) => {
|
||||||
|
if (!active) return;
|
||||||
|
const monthsCount = subInfo.months;
|
||||||
|
const monthLabel = monthsCount === 1 ? 'month' : 'months';
|
||||||
|
const resubMessage = `resubscribed for ${monthsCount} ${monthLabel}${subInfo.message ? ` - ${subInfo.message}` : ''}`;
|
||||||
|
|
||||||
|
const badgeImages: Record<string, string> = {};
|
||||||
|
const badgeDetails: Record<string, { url: string; title: string }> = {};
|
||||||
|
msg.userInfo.badges.forEach((version, badgeName) => {
|
||||||
|
const detail = getBadgeDetail(badgeName, version);
|
||||||
|
if (detail) {
|
||||||
|
badgeImages[badgeName] = detail.url;
|
||||||
|
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatMessage: ChatMessage = {
|
||||||
|
id: msg.id,
|
||||||
|
platform: 'twitch',
|
||||||
|
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
|
||||||
|
content: resubMessage,
|
||||||
|
timestamp: msg.date,
|
||||||
|
userId: msg.userInfo.userId,
|
||||||
|
authorColor: msg.userInfo.color || '#FFFFFF',
|
||||||
|
mentionsUser: false,
|
||||||
|
emotes: {},
|
||||||
|
isBot: false,
|
||||||
|
badges: badgeImages,
|
||||||
|
badgeDetails,
|
||||||
|
messageType: 'resub',
|
||||||
|
};
|
||||||
|
|
||||||
|
onMessage(chatMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
newClient.onSubGift((channel, recipient, subInfo, msg) => {
|
||||||
|
if (!active) return;
|
||||||
|
const tier = subInfo.plan === '3000' ? '3' : subInfo.plan === '2000' ? '2' : '1';
|
||||||
|
const giftMessage = `gifted a Tier ${tier} subscription to ${recipient}`;
|
||||||
|
|
||||||
|
const badgeImages: Record<string, string> = {};
|
||||||
|
const badgeDetails: Record<string, { url: string; title: string }> = {};
|
||||||
|
msg.userInfo.badges.forEach((version, badgeName) => {
|
||||||
|
const detail = getBadgeDetail(badgeName, version);
|
||||||
|
if (detail) {
|
||||||
|
badgeImages[badgeName] = detail.url;
|
||||||
|
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatMessage: ChatMessage = {
|
||||||
|
id: msg.id,
|
||||||
|
platform: 'twitch',
|
||||||
|
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
|
||||||
|
content: giftMessage,
|
||||||
|
timestamp: msg.date,
|
||||||
|
userId: msg.userInfo.userId,
|
||||||
|
authorColor: msg.userInfo.color || '#FFFFFF',
|
||||||
|
mentionsUser: false,
|
||||||
|
emotes: {},
|
||||||
|
isBot: false,
|
||||||
|
badges: badgeImages,
|
||||||
|
badgeDetails,
|
||||||
|
messageType: 'subgift',
|
||||||
|
};
|
||||||
|
|
||||||
|
onMessage(chatMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle community gifted subs (mass gifts)
|
||||||
|
newClient.onCommunitySub((channel, user, giftInfo, msg) => {
|
||||||
|
if (!active) return;
|
||||||
|
const tier = giftInfo.plan === '3000' ? '3' : giftInfo.plan === '2000' ? '2' : '1';
|
||||||
|
const giftMessage = `gifted ${giftInfo.count} Tier ${tier} subscriptions to the community!`;
|
||||||
|
|
||||||
|
const badgeImages: Record<string, string> = {};
|
||||||
|
const badgeDetails: Record<string, { url: string; title: string }> = {};
|
||||||
|
msg.userInfo.badges.forEach((version, badgeName) => {
|
||||||
|
const detail = getBadgeDetail(badgeName, version);
|
||||||
|
if (detail) {
|
||||||
|
badgeImages[badgeName] = detail.url;
|
||||||
|
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatMessage: ChatMessage = {
|
||||||
|
id: msg.id,
|
||||||
|
platform: 'twitch',
|
||||||
|
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
|
||||||
|
content: giftMessage,
|
||||||
|
timestamp: msg.date,
|
||||||
|
userId: msg.userInfo.userId,
|
||||||
|
authorColor: msg.userInfo.color || '#FFFFFF',
|
||||||
|
mentionsUser: false,
|
||||||
|
emotes: {},
|
||||||
|
isBot: false,
|
||||||
|
badges: badgeImages,
|
||||||
|
badgeDetails,
|
||||||
|
messageType: 'subgift',
|
||||||
|
};
|
||||||
|
|
||||||
|
onMessage(chatMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
newClient.connect();
|
||||||
|
|
||||||
|
setClient(newClient);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (newClient) {
|
||||||
|
newClient.quit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [channelName, accessToken, username, onMessage, enabled, clientId]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
async (message: string, replyToMessageId?: string) => {
|
||||||
|
if (!client || !isConnected) {
|
||||||
|
throw new Error('Not connected to chat');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (replyToMessageId) {
|
||||||
|
// Twurple supports replies natively
|
||||||
|
await client.say(channelName, message, { replyTo: replyToMessageId });
|
||||||
|
} else {
|
||||||
|
await client.say(channelName, message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send message:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, isConnected, channelName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isConnected, sendMessage };
|
||||||
|
}
|
||||||
76
src/lib/badges.ts
Normal file
76
src/lib/badges.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { ApiClient } from '@twurple/api';
|
||||||
|
import { StaticAuthProvider } from '@twurple/auth';
|
||||||
|
import type { HelixChatBadgeSet, HelixChatBadgeVersion } from '@twurple/api';
|
||||||
|
|
||||||
|
// Shorthand for badge data we actually care about in the UI
|
||||||
|
export interface BadgeDetail {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the full HelixChatBadgeSet objects for better metadata
|
||||||
|
let badgesGlobal: Map<string, HelixChatBadgeSet> = new Map();
|
||||||
|
let badgesChannel: Map<string, HelixChatBadgeSet> = new Map();
|
||||||
|
|
||||||
|
// Wipe everything if we switch accounts or reset
|
||||||
|
export function clearBadgeCache(): void {
|
||||||
|
badgesGlobal.clear();
|
||||||
|
badgesChannel.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab badge sets from Twitch. If channelId is null, gets global ones.
|
||||||
|
async function fetchBadges(apiClient: ApiClient, channelId?: string): Promise<Map<string, HelixChatBadgeSet>> {
|
||||||
|
try {
|
||||||
|
const badgeSets = channelId
|
||||||
|
? await apiClient.chat.getChannelBadges(channelId)
|
||||||
|
: await apiClient.chat.getGlobalBadges();
|
||||||
|
|
||||||
|
const map = new Map<string, HelixChatBadgeSet>();
|
||||||
|
badgeSets.forEach(set => map.set(set.id, set));
|
||||||
|
return map;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Twitch ${channelId ? 'channel' : 'global'} badges not loaded:`, error);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull down both global and channel-specific badges so we can show them in chat
|
||||||
|
export async function initializeBadges(channelId: string, accessToken: string, clientId: string): Promise<void> {
|
||||||
|
const authProvider = new StaticAuthProvider(clientId, accessToken);
|
||||||
|
const apiClient = new ApiClient({ authProvider });
|
||||||
|
|
||||||
|
const [global, channel] = await Promise.all([
|
||||||
|
fetchBadges(apiClient),
|
||||||
|
fetchBadges(apiClient, channelId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
badgesGlobal = global;
|
||||||
|
badgesChannel = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the raw image URL for a specific badge/version
|
||||||
|
export function getBadge(name: string, version: string): string | undefined {
|
||||||
|
const detail = getBadgeDetail(name, version);
|
||||||
|
return detail?.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the full badge info, checking the channel first (custom badges) then global ones
|
||||||
|
export function getBadgeDetail(name: string, version: string): BadgeDetail | undefined {
|
||||||
|
// Look in channel badges first, then global
|
||||||
|
const set = badgesChannel.get(name) || badgesGlobal.get(name);
|
||||||
|
if (!set) return undefined;
|
||||||
|
|
||||||
|
const v = set.getVersion(version);
|
||||||
|
if (!v) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
version: version,
|
||||||
|
url: v.getImageUrl(2) || v.getImageUrl(1), // Default to 2x for better appearance
|
||||||
|
title: v.title,
|
||||||
|
description: v.description
|
||||||
|
};
|
||||||
|
}
|
||||||
777
src/lib/emotes.ts
Normal file
777
src/lib/emotes.ts
Normal file
@@ -0,0 +1,777 @@
|
|||||||
|
// Emote sources: 7TV, BTTV, FFZ
|
||||||
|
import DOMPurify, { type Config as DOMPurifyConfig } from "dompurify";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOMPurify configuration — only the elements and attributes our emote
|
||||||
|
* renderer ever emits are permitted. Everything else is stripped.
|
||||||
|
*/
|
||||||
|
const DOMPURIFY_CONFIG: DOMPurifyConfig = {
|
||||||
|
ALLOWED_TAGS: ["img", "a", "strong", "span"],
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
// <img>
|
||||||
|
"src", "alt", "title", "class", "style", "loading",
|
||||||
|
"data-emote-name", "data-tooltip-url",
|
||||||
|
// <a>
|
||||||
|
"href", "target", "rel",
|
||||||
|
],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
FORCE_BODY: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMOTE_SIZE = "2";
|
||||||
|
const CACHE_DURATION_DAYS = 7;
|
||||||
|
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||||
|
const CACHE_DURATION = CACHE_DURATION_DAYS * MS_PER_DAY;
|
||||||
|
const EMOTE_BATCH_SIZE = 100;
|
||||||
|
const MAX_REPLACEMENTS = 1000;
|
||||||
|
|
||||||
|
interface EmoteMap {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmoteCache {
|
||||||
|
emotes: EmoteMap;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let emotes7tv: EmoteMap = {};
|
||||||
|
let emotesBttv: EmoteMap = {};
|
||||||
|
let emotesFfz: EmoteMap = {};
|
||||||
|
let emotesTwitch: EmoteMap = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached emotes if valid
|
||||||
|
*/
|
||||||
|
function getCachedEmotes(cacheKey: string): EmoteMap | null {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(cacheKey);
|
||||||
|
if (!cached) return null;
|
||||||
|
|
||||||
|
const cacheData: EmoteCache = JSON.parse(cached);
|
||||||
|
const isExpired = Date.now() - cacheData.timestamp > CACHE_DURATION;
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
localStorage.removeItem(cacheKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheData.emotes;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save emotes to cache
|
||||||
|
*/
|
||||||
|
function setCachedEmotes(cacheKey: string, emotes: EmoteMap): void {
|
||||||
|
try {
|
||||||
|
const cacheData: EmoteCache = {
|
||||||
|
emotes,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify(cacheData));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to cache emotes:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all emote caches
|
||||||
|
*/
|
||||||
|
export function clearEmoteCache(): void {
|
||||||
|
const cacheKeys = ["emotes_twitch_global"];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith("emotes_")) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emotes7tv = {};
|
||||||
|
emotesBttv = {};
|
||||||
|
emotesFfz = {};
|
||||||
|
emotesTwitch = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch 7TV global emotes
|
||||||
|
*/
|
||||||
|
async function fetch7TVGlobalEmotes(): Promise<EmoteMap> {
|
||||||
|
const cacheKey = "emotes_7tv_global";
|
||||||
|
const cached = getCachedEmotes(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
console.log('Using cached 7TV global emotes, count:', Object.keys(cached).length);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Fetching 7TV global emotes from 7tv.io');
|
||||||
|
const response = await fetch("https://7tv.io/v3/emote-sets/global");
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const emotes: EmoteMap = {};
|
||||||
|
if (data.emotes) {
|
||||||
|
data.emotes.forEach((emote: any) => {
|
||||||
|
emotes[emote.name] =
|
||||||
|
`https://cdn.7tv.app/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setCachedEmotes(cacheKey, emotes);
|
||||||
|
console.log('Fetched 7TV global emotes, count:', Object.keys(emotes).length);
|
||||||
|
return emotes;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("7TV global emotes not loaded", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch 7TV emotes for a given channel
|
||||||
|
*/
|
||||||
|
async function fetch7TVEmotes(channelId: string): Promise<EmoteMap> {
|
||||||
|
const cacheKey = `emotes_7tv_${channelId}`;
|
||||||
|
const cached = getCachedEmotes(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://7tv.io/v3/users/twitch/${channelId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const emotes: EmoteMap = {};
|
||||||
|
if (data.emote_set?.emotes) {
|
||||||
|
data.emote_set.emotes.forEach((emote: any) => {
|
||||||
|
emotes[emote.name] =
|
||||||
|
`https://cdn.7tv.app/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setCachedEmotes(cacheKey, emotes);
|
||||||
|
return emotes;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("7TV emotes not loaded", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch BTTV global emotes
|
||||||
|
*/
|
||||||
|
async function fetchBTTVGlobalEmotes(): Promise<EmoteMap> {
|
||||||
|
const cacheKey = "emotes_bttv_global";
|
||||||
|
const cached = getCachedEmotes(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
"https://api.betterttv.net/3/cached/emotes/global",
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const emotes: EmoteMap = {};
|
||||||
|
data.forEach((emote: any) => {
|
||||||
|
emotes[emote.code] =
|
||||||
|
`https://cdn.betterttv.net/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
|
||||||
|
});
|
||||||
|
|
||||||
|
setCachedEmotes(cacheKey, emotes);
|
||||||
|
return emotes;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("BTTV global emotes not loaded", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch BTTV emotes for a given channel
|
||||||
|
*/
|
||||||
|
async function fetchBTTVEmotes(channelId: string): Promise<EmoteMap> {
|
||||||
|
const cacheKey = `emotes_bttv_${channelId}`;
|
||||||
|
const cached = getCachedEmotes(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.betterttv.net/3/cached/users/twitch/${channelId}`,
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const emotes: EmoteMap = {};
|
||||||
|
if (data.channelEmotes) {
|
||||||
|
data.channelEmotes.forEach((emote: any) => {
|
||||||
|
emotes[emote.code] =
|
||||||
|
`https://cdn.betterttv.net/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.sharedEmotes) {
|
||||||
|
data.sharedEmotes.forEach((emote: any) => {
|
||||||
|
emotes[emote.code] =
|
||||||
|
`https://cdn.betterttv.net/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setCachedEmotes(cacheKey, emotes);
|
||||||
|
return emotes;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("BTTV emotes not loaded", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch FFZ global emotes
|
||||||
|
*/
|
||||||
|
async function fetchFFZGlobalEmotes(): Promise<EmoteMap> {
|
||||||
|
const cacheKey = "emotes_ffz_global";
|
||||||
|
const cached = getCachedEmotes(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://api.frankerfacez.com/v1/set/global");
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const emotes: EmoteMap = {};
|
||||||
|
if (data.sets) {
|
||||||
|
Object.values(data.sets).forEach((set: any) => {
|
||||||
|
if (set.emoticons) {
|
||||||
|
set.emoticons.forEach((emote: any) => {
|
||||||
|
emotes[emote.name] =
|
||||||
|
`https://cdn.frankerfacez.com/emote/${emote.id}/${EMOTE_SIZE}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setCachedEmotes(cacheKey, emotes);
|
||||||
|
return emotes;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("FFZ global emotes not loaded", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch FFZ emotes for a given channel
|
||||||
|
*/
|
||||||
|
async function fetchFFZEmotes(channelName: string): Promise<EmoteMap> {
|
||||||
|
const cacheKey = `emotes_ffz_${channelName}`;
|
||||||
|
const cached = getCachedEmotes(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.frankerfacez.com/v1/room/${channelName}`,
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const emotes: EmoteMap = {};
|
||||||
|
if (data.sets) {
|
||||||
|
const setId = data.room.set;
|
||||||
|
if (data.sets[setId]) {
|
||||||
|
data.sets[setId].emoticons.forEach((emote: any) => {
|
||||||
|
emotes[emote.name] =
|
||||||
|
`https://cdn.frankerfacez.com/emote/${emote.id}/${EMOTE_SIZE}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCachedEmotes(cacheKey, emotes);
|
||||||
|
return emotes;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("FFZ emotes not loaded", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Twitch global emotes
|
||||||
|
*/
|
||||||
|
async function fetchTwitchGlobalEmotes(): Promise<EmoteMap> {
|
||||||
|
const cacheKey = "emotes_twitch_global";
|
||||||
|
const cached = getCachedEmotes(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Route through server-side proxy — token comes from httpOnly cookie, not localStorage.
|
||||||
|
const response = await fetch("/api/twitch-proxy?path=/helix/chat/emotes/global");
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const emotes: EmoteMap = {};
|
||||||
|
if (data.data) {
|
||||||
|
data.data.forEach((emote: any) => {
|
||||||
|
const url = emote.images.url_1x.replace("/1.0", "/2.0");
|
||||||
|
emotes[emote.name] = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setCachedEmotes(cacheKey, emotes);
|
||||||
|
return emotes;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Twitch global emotes not loaded", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Twitch channel emotes
|
||||||
|
*/
|
||||||
|
async function fetchTwitchChannelEmotes(channelId: string): Promise<EmoteMap> {
|
||||||
|
const cacheKey = `emotes_twitch_channel_${channelId}`;
|
||||||
|
const cached = getCachedEmotes(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Route through server-side proxy — token comes from httpOnly cookie, not localStorage.
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/twitch-proxy?path=/helix/chat/emotes&broadcaster_id=${encodeURIComponent(channelId)}`,
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const emotes: EmoteMap = {};
|
||||||
|
if (data.data) {
|
||||||
|
data.data.forEach((emote: any) => {
|
||||||
|
const url = emote.images.url_1x.replace("/1.0", "/2.0");
|
||||||
|
emotes[emote.name] = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setCachedEmotes(cacheKey, emotes);
|
||||||
|
return emotes;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Twitch channel emotes not loaded", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user's available emote sets (includes subscriptions)
|
||||||
|
*/
|
||||||
|
async function fetchUserEmoteSets(userId: string): Promise<EmoteMap> {
|
||||||
|
const cacheKey = `emotes_twitch_user_${userId}`;
|
||||||
|
const cached = getCachedEmotes(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emotes: EmoteMap = {};
|
||||||
|
let cursor: string | null = null;
|
||||||
|
let pageCount = 0;
|
||||||
|
|
||||||
|
// Fetch all pages
|
||||||
|
do {
|
||||||
|
// Route through server-side proxy — token comes from httpOnly cookie, not localStorage.
|
||||||
|
const proxyPath = cursor
|
||||||
|
? `/api/twitch-proxy?path=/helix/chat/emotes/user&user_id=${encodeURIComponent(userId)}&after=${encodeURIComponent(cursor)}`
|
||||||
|
: `/api/twitch-proxy?path=/helix/chat/emotes/user&user_id=${encodeURIComponent(userId)}`;
|
||||||
|
|
||||||
|
const apiResponse: Response = await fetch(proxyPath);
|
||||||
|
|
||||||
|
if (!apiResponse.ok) {
|
||||||
|
const errorText = await apiResponse.text();
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch user emotes:",
|
||||||
|
apiResponse.status,
|
||||||
|
apiResponse.statusText,
|
||||||
|
errorText,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageData: any = await apiResponse.json();
|
||||||
|
pageCount++;
|
||||||
|
|
||||||
|
if (pageData.data) {
|
||||||
|
pageData.data.forEach((emote: any) => {
|
||||||
|
const emoteId = emote.id;
|
||||||
|
// Prefer animated if available, otherwise static
|
||||||
|
const format = emote.format?.includes("animated")
|
||||||
|
? "animated"
|
||||||
|
: "static";
|
||||||
|
const url = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteId}/${format}/dark/2.0`;
|
||||||
|
emotes[emote.name] = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next cursor for pagination
|
||||||
|
cursor = pageData.pagination?.cursor || null;
|
||||||
|
} while (cursor);
|
||||||
|
|
||||||
|
setCachedEmotes(cacheKey, emotes);
|
||||||
|
return emotes;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch user emote sets:", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize emotes for a given channel
|
||||||
|
*/
|
||||||
|
export async function initializeEmotes(
|
||||||
|
channelId: string,
|
||||||
|
channelName: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const promises = [
|
||||||
|
fetch7TVGlobalEmotes(),
|
||||||
|
fetch7TVEmotes(channelId),
|
||||||
|
fetchBTTVGlobalEmotes(),
|
||||||
|
fetchBTTVEmotes(channelId),
|
||||||
|
fetchFFZGlobalEmotes(),
|
||||||
|
fetchFFZEmotes(channelName),
|
||||||
|
fetchTwitchGlobalEmotes(),
|
||||||
|
fetchTwitchChannelEmotes(channelId),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
promises.push(fetchUserEmoteSets(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
emotes7tv = {
|
||||||
|
...results[0],
|
||||||
|
...results[1],
|
||||||
|
};
|
||||||
|
|
||||||
|
emotesBttv = {
|
||||||
|
...results[2],
|
||||||
|
...results[3],
|
||||||
|
};
|
||||||
|
|
||||||
|
emotesFfz = {
|
||||||
|
...results[4],
|
||||||
|
...results[5],
|
||||||
|
};
|
||||||
|
|
||||||
|
const globalEmotes = results[6];
|
||||||
|
const channelEmotes = results[7];
|
||||||
|
const userEmotes = userId ? results[8] : {};
|
||||||
|
|
||||||
|
emotesTwitch = {
|
||||||
|
...globalEmotes,
|
||||||
|
...channelEmotes,
|
||||||
|
...userEmotes,
|
||||||
|
};
|
||||||
|
|
||||||
|
// User emotes have been merged into emotesTwitch map
|
||||||
|
if (userId && Object.keys(userEmotes).length > 0) {
|
||||||
|
// Emotes successfully loaded and merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch a custom event to notify listeners that emotes have been reloaded
|
||||||
|
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
|
||||||
|
window.dispatchEvent(new Event('emotes-reloaded'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an optimized emote image HTML tag
|
||||||
|
*/
|
||||||
|
function replaceSize(url: string): string {
|
||||||
|
const parts = url.split("/");
|
||||||
|
// Replace the first number in the last part with '4'
|
||||||
|
// This works for: 1x.webp -> 4x.webp, 1.0 -> 4.0, 2 -> 4, etc.
|
||||||
|
|
||||||
|
if (url.includes('betterttv')) {
|
||||||
|
parts[parts.length - 1] = parts[parts.length - 1].replace(/\d+/, "3");
|
||||||
|
} else {
|
||||||
|
parts[parts.length - 1] = parts[parts.length - 1].replace(/\d+/, "4");
|
||||||
|
}
|
||||||
|
return parts.join("/");
|
||||||
|
}
|
||||||
|
function createEmoteImg(emoteUrl: string, emoteName: string): string {
|
||||||
|
// Convert any quality URL to 4x quality for tooltip (highest resolution)
|
||||||
|
let highQualityUrl = emoteUrl;
|
||||||
|
const splitedUrlSize = emoteUrl.split("/").pop();
|
||||||
|
if (
|
||||||
|
splitedUrlSize &&
|
||||||
|
(splitedUrlSize.includes("2") || splitedUrlSize.includes("1"))
|
||||||
|
) {
|
||||||
|
highQualityUrl = replaceSize(emoteUrl);
|
||||||
|
}
|
||||||
|
return `<img src="${emoteUrl}" alt="${escapeHtml(emoteName)}" title="${escapeHtml(emoteName)}" data-tooltip-url="${highQualityUrl}" data-emote-name="${escapeHtml(emoteName)}" class="chat-emote" style="height: 2em; vertical-align: middle; margin: 0 2px; cursor: pointer;" loading="lazy" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse message text and replace emote codes with HTML image tags
|
||||||
|
* @param text - The message text
|
||||||
|
* @param twitchEmotes - Twitch emote data from message tags (emoteId -> positions array)
|
||||||
|
*/
|
||||||
|
export function parseMessageWithEmotes(
|
||||||
|
text: string,
|
||||||
|
twitchEmotes?: Record<string, string[]>,
|
||||||
|
): string {
|
||||||
|
let raw: string;
|
||||||
|
|
||||||
|
if (twitchEmotes && Object.keys(twitchEmotes).length > 0) {
|
||||||
|
const replacements: Array<{ start: number; end: number; html: string }> =
|
||||||
|
[];
|
||||||
|
|
||||||
|
Object.entries(twitchEmotes).forEach(([emoteId, positions]) => {
|
||||||
|
positions.forEach((pos) => {
|
||||||
|
const [start, end] = pos.split("-").map(Number);
|
||||||
|
const emoteName = text.substring(start, end + 1);
|
||||||
|
const emoteUrl = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteId}/default/dark/2.0`;
|
||||||
|
replacements.push({
|
||||||
|
start,
|
||||||
|
end: end + 1,
|
||||||
|
html: createEmoteImg(emoteUrl, emoteName),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
replacements.sort((a, b) => b.start - a.start);
|
||||||
|
|
||||||
|
let result = text;
|
||||||
|
replacements.forEach(({ start, end, html }) => {
|
||||||
|
result = result.substring(0, start) + html + result.substring(end);
|
||||||
|
});
|
||||||
|
|
||||||
|
raw = parseThirdPartyEmotes(result);
|
||||||
|
} else {
|
||||||
|
raw = parseThirdPartyEmotes(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final sanitization pass — strip anything outside the known-safe allowlist.
|
||||||
|
// This is the last line of defence against XSS from malformed emote names,
|
||||||
|
// crafted CDN responses, or future changes to the HTML-building helpers.
|
||||||
|
return DOMPurify.sanitize(raw, DOMPURIFY_CONFIG) as unknown as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse third-party emotes and @mentions in text
|
||||||
|
*/
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a word is a URL and convert to clickable link
|
||||||
|
*/
|
||||||
|
function linkifyUrl(word: string): string {
|
||||||
|
// Only match http(s) or www-prefixed URLs
|
||||||
|
const urlPattern = /^(https?:\/\/|www\.)[^\s]+$/i;
|
||||||
|
|
||||||
|
if (urlPattern.test(word)) {
|
||||||
|
let url = word;
|
||||||
|
if (word.toLowerCase().startsWith("www.")) {
|
||||||
|
url = "https://" + word;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly block dangerous schemes even if the regex above is ever relaxed.
|
||||||
|
// URL objects normalise the protocol to lowercase, making detection reliable.
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const scheme = parsed.protocol; // e.g. "javascript:", "data:", "https:"
|
||||||
|
if (scheme !== "https:" && scheme !== "http:") {
|
||||||
|
return escapeHtml(word); // render as plain text
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return escapeHtml(word); // invalid URL — render as plain text
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="color: #6366f1; text-decoration: underline; cursor: pointer;">${escapeHtml(word)}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse third-party emotes and @mentions in text
|
||||||
|
*/
|
||||||
|
function parseThirdPartyEmotes(text: string): string {
|
||||||
|
// Split by HTML tags to avoid breaking emote img tags
|
||||||
|
// We use a capture group so the delimiter (img tag) is included in the result array
|
||||||
|
const parts = text.split(/(<img[^>]*>)/);
|
||||||
|
|
||||||
|
const parsedParts = parts.map((part) => {
|
||||||
|
// Skip if it's already an HTML tag (our emote)
|
||||||
|
if (part.startsWith("<img")) {
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into words and parse
|
||||||
|
// Note: This approach loses exact whitespace formatting (collapses multiple spaces)
|
||||||
|
// A more robust approach would be to find matches and replace them, escaping the rest.
|
||||||
|
// For now, we'll stick to splitting by space but ensure we escape non-matches.
|
||||||
|
const words = part.split(" ");
|
||||||
|
const parsedWords = words.map((word) => {
|
||||||
|
// Check for URLs first
|
||||||
|
const linkified = linkifyUrl(word);
|
||||||
|
if (linkified) {
|
||||||
|
return linkified;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for @mention
|
||||||
|
if (word.startsWith("@")) {
|
||||||
|
return `<strong>${escapeHtml(word)}</strong>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Twitch native emotes (from cache map, manually typed)
|
||||||
|
if (emotesTwitch[word]) {
|
||||||
|
return createEmoteImg(emotesTwitch[word], word);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 7TV
|
||||||
|
if (emotes7tv[word]) {
|
||||||
|
return createEmoteImg(emotes7tv[word], word);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check BTTV
|
||||||
|
if (emotesBttv[word]) {
|
||||||
|
return createEmoteImg(emotesBttv[word], word);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check FFZ
|
||||||
|
if (emotesFfz[word]) {
|
||||||
|
return createEmoteImg(emotesFfz[word], word);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return escaped word
|
||||||
|
return escapeHtml(word);
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsedWords.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsedParts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for emotes matching a query
|
||||||
|
*/
|
||||||
|
export function searchEmotes(
|
||||||
|
query: string,
|
||||||
|
limit: number = 10,
|
||||||
|
): Array<{ name: string; url: string; source: string }> {
|
||||||
|
if (!query) return [];
|
||||||
|
|
||||||
|
const results: Array<{ name: string; url: string; source: string }> = [];
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
// Debug: Show first 5 Twitch emote names
|
||||||
|
|
||||||
|
// Search Twitch emotes (includes global, channel, and user's subscriptions)
|
||||||
|
Object.entries(emotesTwitch).forEach(([name, url]) => {
|
||||||
|
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
|
||||||
|
results.push({ name, url, source: "Twitch" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search 7TV emotes
|
||||||
|
Object.entries(emotes7tv).forEach(([name, url]) => {
|
||||||
|
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
|
||||||
|
results.push({ name, url, source: "7TV" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search BTTV emotes
|
||||||
|
Object.entries(emotesBttv).forEach(([name, url]) => {
|
||||||
|
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
|
||||||
|
results.push({ name, url, source: "BTTV" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search FFZ emotes
|
||||||
|
Object.entries(emotesFfz).forEach(([name, url]) => {
|
||||||
|
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
|
||||||
|
results.push({ name, url, source: "FFZ" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by relevance (exact match first, then starts with, then contains)
|
||||||
|
results.sort((a, b) => {
|
||||||
|
const aLower = a.name.toLowerCase();
|
||||||
|
const bLower = b.name.toLowerCase();
|
||||||
|
|
||||||
|
if (aLower === lowerQuery) return -1;
|
||||||
|
if (bLower === lowerQuery) return 1;
|
||||||
|
if (aLower.startsWith(lowerQuery) && !bLower.startsWith(lowerQuery))
|
||||||
|
return -1;
|
||||||
|
if (bLower.startsWith(lowerQuery) && !aLower.startsWith(lowerQuery))
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalResults = results.slice(0, limit);
|
||||||
|
|
||||||
|
return finalResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disabled emotes management (stored in localStorage as array of names)
|
||||||
|
*/
|
||||||
|
const DISABLED_EMOTES_KEY = 'disabled_emotes_v1';
|
||||||
|
|
||||||
|
export function getDisabledEmotes(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(DISABLED_EMOTES_KEY);
|
||||||
|
if (!raw) return new Set();
|
||||||
|
const arr = JSON.parse(raw) as string[];
|
||||||
|
return new Set(arr);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmoteDisabled(name: string): boolean {
|
||||||
|
return getDisabledEmotes().has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleDisableEmote(name: string): void {
|
||||||
|
try {
|
||||||
|
const set = getDisabledEmotes();
|
||||||
|
if (set.has(name)) set.delete(name);
|
||||||
|
else set.add(name);
|
||||||
|
localStorage.setItem(DISABLED_EMOTES_KEY, JSON.stringify(Array.from(set)));
|
||||||
|
|
||||||
|
// Notify listeners (reuse emotes-reloaded so UI refreshes emote lists and autocomplete)
|
||||||
|
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
|
||||||
|
window.dispatchEvent(new Event('emotes-reloaded'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to toggle emote disabled state', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all emotes organized by source
|
||||||
|
*/
|
||||||
|
export interface EmotesBySource {
|
||||||
|
twitch: Array<{ name: string; url: string }>;
|
||||||
|
"7tv": Array<{ name: string; url: string }>;
|
||||||
|
bttv: Array<{ name: string; url: string }>;
|
||||||
|
ffz: Array<{ name: string; url: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllEmotesOrganized(): EmotesBySource {
|
||||||
|
const organized = {
|
||||||
|
twitch: Object.entries(emotesTwitch)
|
||||||
|
.map(([name, url]) => ({ name, url }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
"7tv": Object.entries(emotes7tv)
|
||||||
|
.map(([name, url]) => ({ name, url }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
bttv: Object.entries(emotesBttv)
|
||||||
|
.map(([name, url]) => ({ name, url }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
ffz: Object.entries(emotesFfz)
|
||||||
|
.map(([name, url]) => ({ name, url }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug log to verify emotes are loaded
|
||||||
|
console.log("Emotes organized:", {
|
||||||
|
twitch: organized.twitch.length,
|
||||||
|
"7tv": organized["7tv"].length,
|
||||||
|
bttv: organized.bttv.length,
|
||||||
|
ffz: organized.ffz.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return organized;
|
||||||
|
}
|
||||||
129
src/lib/streamMonitor.ts
Normal file
129
src/lib/streamMonitor.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { getStreamStatuses } from "./twitch";
|
||||||
|
import type { PlatformSession } from "./types";
|
||||||
|
|
||||||
|
export interface StreamerToWatch {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
profileImageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveStreamer extends StreamerToWatch {
|
||||||
|
isLive: true;
|
||||||
|
title?: string;
|
||||||
|
viewerCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which streamers from a list are currently live
|
||||||
|
* Uses batch API endpoint to check all streamers in a single request
|
||||||
|
*/
|
||||||
|
export async function checkLiveStreamers(
|
||||||
|
streamersToWatch: StreamerToWatch[],
|
||||||
|
accessToken: string,
|
||||||
|
clientId: string,
|
||||||
|
): Promise<LiveStreamer[]> {
|
||||||
|
if (!streamersToWatch.length) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streamerIds = streamersToWatch.map((s) => s.id);
|
||||||
|
|
||||||
|
// Use batch endpoint to check all streamers in one API call
|
||||||
|
const statuses = await getStreamStatuses(
|
||||||
|
accessToken,
|
||||||
|
clientId,
|
||||||
|
streamerIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const liveStreamers: LiveStreamer[] = streamersToWatch
|
||||||
|
.filter((streamer) => statuses[streamer.id]?.isLive)
|
||||||
|
.map((streamer) => ({
|
||||||
|
...streamer,
|
||||||
|
isLive: true as const,
|
||||||
|
title: statuses[streamer.id]?.title,
|
||||||
|
viewerCount: statuses[streamer.id]?.viewerCount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return liveStreamers;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking live streamers:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get watched streamers from localStorage
|
||||||
|
*/
|
||||||
|
export function getWatchedStreamers(): StreamerToWatch[] {
|
||||||
|
const saved = localStorage.getItem("mixchat_watched_streamers");
|
||||||
|
if (!saved) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(saved);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse watched streamers:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save watched streamers to localStorage
|
||||||
|
*/
|
||||||
|
export function saveWatchedStreamers(streamers: StreamerToWatch[]): void {
|
||||||
|
localStorage.setItem("mixchat_watched_streamers", JSON.stringify(streamers));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a streamer to watch list
|
||||||
|
*/
|
||||||
|
export function addWatchedStreamer(streamer: StreamerToWatch): void {
|
||||||
|
const current = getWatchedStreamers();
|
||||||
|
if (!current.find((s) => s.id === streamer.id)) {
|
||||||
|
saveWatchedStreamers([...current, streamer]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a streamer from watch list
|
||||||
|
*/
|
||||||
|
export function removeWatchedStreamer(streamerId: string): void {
|
||||||
|
const current = getWatchedStreamers();
|
||||||
|
saveWatchedStreamers(current.filter((s) => s.id !== streamerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which streamers have already been notified for
|
||||||
|
*/
|
||||||
|
export function getNotifiedStreamers(): Set<string> {
|
||||||
|
const saved = localStorage.getItem("mixchat_notified_streamers");
|
||||||
|
if (!saved) return new Set();
|
||||||
|
try {
|
||||||
|
return new Set(JSON.parse(saved));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse notified streamers:", error);
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a streamer as notified
|
||||||
|
*/
|
||||||
|
export function markStreamerNotified(streamerId: string): void {
|
||||||
|
const notified = getNotifiedStreamers();
|
||||||
|
notified.add(streamerId);
|
||||||
|
localStorage.setItem(
|
||||||
|
"mixchat_notified_streamers",
|
||||||
|
JSON.stringify(Array.from(notified)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a streamer as not notified (when they go offline)
|
||||||
|
*/
|
||||||
|
export function markStreamerOffline(streamerId: string): void {
|
||||||
|
const notified = getNotifiedStreamers();
|
||||||
|
notified.delete(streamerId);
|
||||||
|
localStorage.setItem(
|
||||||
|
"mixchat_notified_streamers",
|
||||||
|
JSON.stringify(Array.from(notified)),
|
||||||
|
);
|
||||||
|
}
|
||||||
629
src/lib/twitch.ts
Normal file
629
src/lib/twitch.ts
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
import type { PlatformSession, ChatMessage } from "./types.js";
|
||||||
|
import { getBadge, getBadgeDetail } from "./badges.js";
|
||||||
|
|
||||||
|
const TWITCH_API_BASE = "https://api.twitch.tv/helix";
|
||||||
|
const TWITCH_AUTH_BASE = "https://id.twitch.tv/oauth2";
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
export const getTwitchAccessToken = async (
|
||||||
|
code: string,
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
redirectUri: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${TWITCH_AUTH_BASE}/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
expiresIn: data.expires_in,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Twitch token exchange failed:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const refreshTwitchAccessToken = async (
|
||||||
|
refreshToken: string,
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${TWITCH_AUTH_BASE}/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to refresh token: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token || refreshToken,
|
||||||
|
expiresIn: data.expires_in,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Twitch token refresh failed:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTwitchUser = async (accessToken: string, clientId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${TWITCH_API_BASE}/users`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Client-ID": clientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Twitch API error: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.data || !data.data[0]) {
|
||||||
|
throw new Error("No user data returned from Twitch API");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = data.data[0];
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
displayName: user.display_name,
|
||||||
|
login: user.login,
|
||||||
|
profileImageUrl: user.profile_image_url,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get Twitch user:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTwitchUserById = async (
|
||||||
|
accessToken: string,
|
||||||
|
clientId: string,
|
||||||
|
userId: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${TWITCH_API_BASE}/users?id=${userId}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Client-ID": clientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Twitch API error: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.data || !data.data[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = data.data[0];
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
displayName: user.display_name,
|
||||||
|
login: user.login,
|
||||||
|
profileImageUrl: user.profile_image_url,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get Twitch user by ID:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const getFollowedChannels = async (
|
||||||
|
accessToken: string,
|
||||||
|
clientId: string,
|
||||||
|
userId: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
let followedChannels: any[] = [];
|
||||||
|
let cursor: string | undefined = undefined;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const url = new URL(`${TWITCH_API_BASE}/channels/followed`);
|
||||||
|
url.searchParams.set("user_id", userId);
|
||||||
|
url.searchParams.set("first", "100");
|
||||||
|
if (cursor) {
|
||||||
|
url.searchParams.set("after", cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Client-ID": clientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error(
|
||||||
|
`Twitch API error: 401 - Unauthorized. Token may be expired or invalid.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`Twitch API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const pageChannels = (data.data || []).map((channel: any) => ({
|
||||||
|
id: channel.broadcaster_id,
|
||||||
|
name: channel.broadcaster_login,
|
||||||
|
displayName:
|
||||||
|
channel.broadcaster_name ||
|
||||||
|
channel.display_name ||
|
||||||
|
channel.broadcaster_login,
|
||||||
|
title: channel.game_name || "",
|
||||||
|
thumbnail: channel.thumbnail_url,
|
||||||
|
}));
|
||||||
|
|
||||||
|
followedChannels = followedChannels.concat(pageChannels);
|
||||||
|
|
||||||
|
if (data.pagination?.cursor) {
|
||||||
|
cursor = data.pagination.cursor;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = BATCH_SIZE;
|
||||||
|
for (let i = 0; i < followedChannels.length; i += batchSize) {
|
||||||
|
const batch = followedChannels.slice(i, i + batchSize);
|
||||||
|
const userIds = batch.map((ch: any) => `id=${ch.id}`).join("&");
|
||||||
|
|
||||||
|
const usersResponse = await fetch(`${TWITCH_API_BASE}/users?${userIds}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Client-ID": clientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (usersResponse.ok) {
|
||||||
|
const usersData = await usersResponse.json();
|
||||||
|
const userProfiles = new Map(
|
||||||
|
(usersData.data || []).map((user: any) => [
|
||||||
|
user.id,
|
||||||
|
user.profile_image_url,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
batch.forEach((ch: any) => {
|
||||||
|
ch.profileImageUrl = userProfiles.get(ch.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamDataMap = new Map<string, any>();
|
||||||
|
|
||||||
|
for (let i = 0; i < followedChannels.length; i += batchSize) {
|
||||||
|
const batch = followedChannels.slice(i, i + batchSize);
|
||||||
|
const userIdParams = batch.map((ch: any) => `user_id=${ch.id}`).join("&");
|
||||||
|
|
||||||
|
const streamsResponse = await fetch(
|
||||||
|
`${TWITCH_API_BASE}/streams?${userIdParams}&first=100`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Client-ID": clientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streamsResponse.ok) {
|
||||||
|
const streamsData = await streamsResponse.json();
|
||||||
|
(streamsData.data || []).forEach((s: any) => {
|
||||||
|
streamDataMap.set(s.user_id, s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
followedChannels.forEach((ch: any) => {
|
||||||
|
if (streamDataMap.has(ch.id)) {
|
||||||
|
ch.viewerCount = streamDataMap.get(ch.id).viewer_count;
|
||||||
|
} else {
|
||||||
|
ch.viewerCount = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only return online channels for the sidebar
|
||||||
|
const onlineChannels = followedChannels.filter((ch: any) =>
|
||||||
|
streamDataMap.has(ch.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sorted = onlineChannels.sort((a, b) => {
|
||||||
|
const nameA = (a?.displayName || a?.name || "").toLowerCase();
|
||||||
|
const nameB = (b?.displayName || b?.name || "").toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get followed channels:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllFollowedChannels = async (
|
||||||
|
accessToken: string,
|
||||||
|
clientId: string,
|
||||||
|
userId: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
let followedChannels: any[] = [];
|
||||||
|
let cursor: string | undefined = undefined;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const url = new URL(`${TWITCH_API_BASE}/channels/followed`);
|
||||||
|
url.searchParams.set("user_id", userId);
|
||||||
|
url.searchParams.set("first", "100");
|
||||||
|
if (cursor) {
|
||||||
|
url.searchParams.set("after", cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Client-ID": clientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error(
|
||||||
|
`Twitch API error: 401 - Unauthorized. Token may be expired or invalid.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`Twitch API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const pageChannels = (data.data || []).map((channel: any) => ({
|
||||||
|
id: channel.broadcaster_id,
|
||||||
|
name: channel.broadcaster_login,
|
||||||
|
displayName:
|
||||||
|
channel.broadcaster_name ||
|
||||||
|
channel.display_name ||
|
||||||
|
channel.broadcaster_login,
|
||||||
|
title: channel.game_name || "",
|
||||||
|
thumbnail: channel.thumbnail_url,
|
||||||
|
}));
|
||||||
|
|
||||||
|
followedChannels = followedChannels.concat(pageChannels);
|
||||||
|
|
||||||
|
if (data.pagination?.cursor) {
|
||||||
|
cursor = data.pagination.cursor;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = BATCH_SIZE;
|
||||||
|
for (let i = 0; i < followedChannels.length; i += batchSize) {
|
||||||
|
const batch = followedChannels.slice(i, i + batchSize);
|
||||||
|
const userIds = batch.map((ch: any) => `id=${ch.id}`).join("&");
|
||||||
|
|
||||||
|
const usersResponse = await fetch(`${TWITCH_API_BASE}/users?${userIds}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Client-ID": clientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (usersResponse.ok) {
|
||||||
|
const usersData = await usersResponse.json();
|
||||||
|
const userProfiles = new Map(
|
||||||
|
(usersData.data || []).map((user: any) => [
|
||||||
|
user.id,
|
||||||
|
user.profile_image_url,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
batch.forEach((ch: any) => {
|
||||||
|
ch.profileImageUrl = userProfiles.get(ch.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch stream data to mark which channels are online
|
||||||
|
const streamDataMap = new Map<string, any>();
|
||||||
|
|
||||||
|
for (let i = 0; i < followedChannels.length; i += batchSize) {
|
||||||
|
const batch = followedChannels.slice(i, i + batchSize);
|
||||||
|
const userIdParams = batch.map((ch: any) => `user_id=${ch.id}`).join("&");
|
||||||
|
|
||||||
|
const streamsResponse = await fetch(
|
||||||
|
`${TWITCH_API_BASE}/streams?${userIdParams}&first=100`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Client-ID": clientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streamsResponse.ok) {
|
||||||
|
const streamsData = await streamsResponse.json();
|
||||||
|
(streamsData.data || []).forEach((s: any) => {
|
||||||
|
streamDataMap.set(s.user_id, s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
followedChannels.forEach((ch: any) => {
|
||||||
|
if (streamDataMap.has(ch.id)) {
|
||||||
|
ch.viewerCount = streamDataMap.get(ch.id).viewer_count;
|
||||||
|
} else {
|
||||||
|
ch.viewerCount = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return all followed channels, sorted with online first
|
||||||
|
const sorted = followedChannels.sort((a, b) => {
|
||||||
|
const aIsOnline = streamDataMap.has(a.id) ? 1 : 0;
|
||||||
|
const bIsOnline = streamDataMap.has(b.id) ? 1 : 0;
|
||||||
|
|
||||||
|
if (aIsOnline !== bIsOnline) {
|
||||||
|
return bIsOnline - aIsOnline; // Online channels first
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameA = (a?.displayName || a?.name || "").toLowerCase();
|
||||||
|
const nameB = (b?.displayName || b?.name || "").toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get all followed channels:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStreamStatus = async (
|
||||||
|
accessToken: string,
|
||||||
|
clientId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{
|
||||||
|
isLive: boolean;
|
||||||
|
title?: string;
|
||||||
|
viewerCount?: number;
|
||||||
|
startedAt?: Date;
|
||||||
|
} | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${TWITCH_API_BASE}/streams?user_id=${userId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Client-ID": clientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Twitch API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const stream = data.data?.[0];
|
||||||
|
|
||||||
|
if (!stream) {
|
||||||
|
return { isLive: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLive: true,
|
||||||
|
title: stream.title,
|
||||||
|
viewerCount: stream.viewer_count,
|
||||||
|
startedAt: new Date(stream.started_at),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get stream status:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch query stream status for multiple users (optimized for rate limiting)
|
||||||
|
* Instead of making N individual requests, makes 1 request with multiple user_ids
|
||||||
|
* Reduces API calls by ~90% when checking multiple streamers
|
||||||
|
*/
|
||||||
|
export const getStreamStatuses = async (
|
||||||
|
accessToken: string,
|
||||||
|
clientId: string,
|
||||||
|
userIds: string[],
|
||||||
|
): Promise<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{ isLive: boolean; title?: string; viewerCount?: number; startedAt?: Date }
|
||||||
|
>
|
||||||
|
> => {
|
||||||
|
try {
|
||||||
|
if (!userIds.length) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query with all user IDs
|
||||||
|
const params = userIds.map((id) => `user_id=${id}`).join("&");
|
||||||
|
const response = await fetch(
|
||||||
|
`${TWITCH_API_BASE}/streams?${params}&first=100`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Client-ID": clientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Twitch API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const result: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
isLive: boolean;
|
||||||
|
title?: string;
|
||||||
|
viewerCount?: number;
|
||||||
|
startedAt?: Date;
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
// Initialize all user IDs as offline
|
||||||
|
userIds.forEach((id) => {
|
||||||
|
result[id] = { isLive: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update with actual live streams
|
||||||
|
data.data?.forEach((stream: any) => {
|
||||||
|
result[stream.user_id] = {
|
||||||
|
isLive: true,
|
||||||
|
title: stream.title,
|
||||||
|
viewerCount: stream.viewer_count,
|
||||||
|
startedAt: new Date(stream.started_at),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Batch stream check: ${userIds.length} streamers checked in 1 request`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get batch stream status:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRecentMessages = async (
|
||||||
|
channelName: string,
|
||||||
|
): Promise<ChatMessage[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://recent-messages.robotty.de/api/v2/recent-messages/${channelName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to fetch recent messages for ${channelName}:`,
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const messages: ChatMessage[] = (data.messages || [])
|
||||||
|
.map((rawMsg: string) => {
|
||||||
|
try {
|
||||||
|
const tagsMatch = rawMsg.match(/^@(.+?)\s+:/);
|
||||||
|
const userMatch = rawMsg.match(/\s:([^!]+)!/);
|
||||||
|
const contentMatch = rawMsg.match(/PRIVMSG #[^\s]+ :(.+)$/);
|
||||||
|
|
||||||
|
if (!userMatch || !contentMatch) {
|
||||||
|
console.warn("Failed to parse message:", rawMsg.substring(0, 100));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const author = userMatch[1];
|
||||||
|
const content = contentMatch[1];
|
||||||
|
|
||||||
|
// Parse tags
|
||||||
|
let tags: Record<string, string> = {};
|
||||||
|
if (tagsMatch && tagsMatch[1]) {
|
||||||
|
const tagPairs = tagsMatch[1].split(";");
|
||||||
|
tags = Object.fromEntries(
|
||||||
|
tagPairs.map((pair) => {
|
||||||
|
const [key, value] = pair.split("=");
|
||||||
|
return [key, value || ""];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let badges: Record<string, string> = {};
|
||||||
|
let badgeDetails: Record<string, { url: string; title: string }> = {};
|
||||||
|
if (tags["badges"]) {
|
||||||
|
tags["badges"].split(",").forEach((badge: string) => {
|
||||||
|
const [name, version] = badge.split("/");
|
||||||
|
const detail = getBadgeDetail(name, version);
|
||||||
|
if (detail) {
|
||||||
|
badges[name] = detail.url;
|
||||||
|
badgeDetails[name] = { url: detail.url, title: detail.title };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let emotes: Record<string, string[]> = {};
|
||||||
|
if (tags["emotes"] && tags["emotes"] !== "") {
|
||||||
|
emotes = Object.fromEntries(
|
||||||
|
tags["emotes"].split("/").map((emote: string) => {
|
||||||
|
const [id, positions] = emote.split(":");
|
||||||
|
return [id, positions.split(",")];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the actual Twitch message ID if available, otherwise fall back to a composite ID
|
||||||
|
const messageId = tags["id"] || `${tags["user-id"] || "unknown"}_${tags["tmi-sent-ts"] || Date.now()}`;
|
||||||
|
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: messageId,
|
||||||
|
platform: "twitch",
|
||||||
|
author,
|
||||||
|
content,
|
||||||
|
timestamp: new Date(parseInt(tags["tmi-sent-ts"]) || Date.now()),
|
||||||
|
userId: tags["user-id"] || "",
|
||||||
|
authorColor: tags["color"] || "#FFFFFF",
|
||||||
|
badges,
|
||||||
|
badgeDetails,
|
||||||
|
emotes,
|
||||||
|
isBot: tags["bot"] === "1",
|
||||||
|
mentionsUser: false,
|
||||||
|
isPreloaded: true, // Mark as preloaded historical message
|
||||||
|
};
|
||||||
|
|
||||||
|
return message;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Error parsing message:",
|
||||||
|
rawMsg.substring(0, 100),
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((msg: ChatMessage | null): msg is ChatMessage => msg !== null);
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch recent messages:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
51
src/lib/types.ts
Normal file
51
src/lib/types.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export const PLATFORMS = ["twitch", "youtube"] as const;
|
||||||
|
export type Platform = (typeof PLATFORMS)[number];
|
||||||
|
|
||||||
|
export const MESSAGE_TYPES = [
|
||||||
|
"chat",
|
||||||
|
"cheer",
|
||||||
|
"subscription",
|
||||||
|
"resub",
|
||||||
|
"subgift",
|
||||||
|
"redemption",
|
||||||
|
] as const;
|
||||||
|
export type MessageType = (typeof MESSAGE_TYPES)[number];
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
platform: Platform;
|
||||||
|
author: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
authorAvatar?: string;
|
||||||
|
authorColor?: string;
|
||||||
|
userId?: string;
|
||||||
|
mentionsUser?: boolean;
|
||||||
|
emotes?: Record<string, string[]>; // Twitch emote data: emoteId -> positions
|
||||||
|
replyTo?: {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
isBot?: boolean;
|
||||||
|
badges?: Record<string, string>; // badgeName -> imageUrl
|
||||||
|
badgeDetails?: Record<string, { url: string; title: string }>; // detailed badge info
|
||||||
|
isCurrentUser?: boolean; // True if message is from the logged-in user
|
||||||
|
optimistic?: boolean; // True if this message is a local optimistic placeholder
|
||||||
|
isPreloaded?: boolean; // True if message is from historical data (not live chat)
|
||||||
|
messageType?: MessageType; // Type of message (chat, cheer, subscription, etc.)
|
||||||
|
rewardId?: string; // ID of the redeemed reward
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformSession {
|
||||||
|
platform: Platform;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
username?: string;
|
||||||
|
profileImageUrl?: string;
|
||||||
|
badges?: Record<string, string>; // badgeName -> imageUrl
|
||||||
|
badgeDetails?: Record<string, { url: string; title: string }>; // detailed badge info
|
||||||
|
}
|
||||||
6
src/lib/version.ts
Normal file
6
src/lib/version.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* App version constant
|
||||||
|
* Update here to reflect current release version
|
||||||
|
*/
|
||||||
|
export const APP_VERSION = "0.5.0-beta";
|
||||||
|
export const DISPLAY_VERSION = "Beta 0.5";
|
||||||
114
src/lib/youtube.ts
Normal file
114
src/lib/youtube.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* YouTube OAuth and API Client
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface YouTubeTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in: number;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YouTubeUser {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
profileImageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange authorization code for access token
|
||||||
|
*/
|
||||||
|
export async function getYoutubeAccessToken(
|
||||||
|
code: string,
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
redirectUri: string,
|
||||||
|
): Promise<YouTubeTokenResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
body: params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to get YouTube access token: ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get YouTube user information
|
||||||
|
*/
|
||||||
|
export async function getYoutubeUser(
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<YouTubeUser> {
|
||||||
|
const response = await fetch(
|
||||||
|
"https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get YouTube user: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.items || data.items.length === 0) {
|
||||||
|
throw new Error("No YouTube channel found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = data.items[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: channel.id,
|
||||||
|
displayName: channel.snippet.title,
|
||||||
|
profileImageUrl: channel.snippet.thumbnails?.default?.url || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh YouTube access token
|
||||||
|
*/
|
||||||
|
export async function refreshYoutubeToken(
|
||||||
|
refreshToken: string,
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
): Promise<YouTubeTokenResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
body: params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to refresh YouTube token: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
50
src/lib/youtubeLinks.ts
Normal file
50
src/lib/youtubeLinks.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* YouTube channel linking helpers.
|
||||||
|
* Stores a mapping of Twitch channel ID -> YouTube channel URL in localStorage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "mixchat_youtube_links";
|
||||||
|
|
||||||
|
export interface YouTubeLink {
|
||||||
|
twitchChannelId: string;
|
||||||
|
twitchDisplayName: string;
|
||||||
|
youtubeUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getYoutubeLinks(): YouTubeLink[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addYoutubeLink(link: YouTubeLink): void {
|
||||||
|
const links = getYoutubeLinks();
|
||||||
|
// Replace if already exists for this twitch channel
|
||||||
|
const idx = links.findIndex(
|
||||||
|
(l) => l.twitchChannelId === link.twitchChannelId,
|
||||||
|
);
|
||||||
|
if (idx >= 0) {
|
||||||
|
links[idx] = link;
|
||||||
|
} else {
|
||||||
|
links.push(link);
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(links));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeYoutubeLink(twitchChannelId: string): void {
|
||||||
|
const links = getYoutubeLinks().filter(
|
||||||
|
(l) => l.twitchChannelId !== twitchChannelId,
|
||||||
|
);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(links));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getYoutubeLinkForChannel(
|
||||||
|
twitchChannelId: string,
|
||||||
|
): string | null {
|
||||||
|
const links = getYoutubeLinks();
|
||||||
|
const found = links.find((l) => l.twitchChannelId === twitchChannelId);
|
||||||
|
return found?.youtubeUrl ?? null;
|
||||||
|
}
|
||||||
13
src/pages/api/parent-domain.ts
Normal file
13
src/pages/api/parent-domain.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
const hostHeader = request.headers.get("host") || "";
|
||||||
|
const host = hostHeader.split(":")[0];
|
||||||
|
const isIpAddress = /^(\d{1,3}\.){3}\d{1,3}$/.test(host);
|
||||||
|
const parentDomain = isIpAddress ? "mixchat.local" : host;
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ parentDomain }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
190
src/pages/api/refresh-token.ts
Normal file
190
src/pages/api/refresh-token.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { refreshTwitchAccessToken } from "../../lib/twitch";
|
||||||
|
import { refreshYoutubeToken } from "../../lib/youtube";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, cookies }) => {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return new Response("Method not allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isDev = import.meta.env.MODE === 'development';
|
||||||
|
const isSecure = !isDev;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const { platform } = body as { platform?: string };
|
||||||
|
|
||||||
|
if (!platform) {
|
||||||
|
return new Response(JSON.stringify({ error: "Platform required" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessToken: string | undefined;
|
||||||
|
let refreshTokenOut: string | undefined;
|
||||||
|
let expiresIn: number | undefined;
|
||||||
|
|
||||||
|
if (platform === "twitch") {
|
||||||
|
// Read the refresh token from the secure httpOnly cookie.
|
||||||
|
const refreshToken = cookies.get("twitch_refresh_token")?.value;
|
||||||
|
if (!refreshToken) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "No refresh token available" }),
|
||||||
|
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
|
||||||
|
const clientSecret = import.meta.env.TWITCH_CLIENT_SECRET;
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
console.error("Missing Twitch credentials in environment");
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Server configuration error" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await refreshTwitchAccessToken(
|
||||||
|
refreshToken,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
);
|
||||||
|
accessToken = tokenData.accessToken;
|
||||||
|
refreshTokenOut = tokenData.refreshToken;
|
||||||
|
expiresIn = tokenData.expiresIn;
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
cookies.set("twitch_token", accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecure,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: expiresIn || 3600,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshTokenOut) {
|
||||||
|
cookies.set("twitch_refresh_token", refreshTokenOut, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecure,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 365 * 24 * 60 * 60,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (platform === "youtube") {
|
||||||
|
// Read the refresh token from the secure httpOnly cookie.
|
||||||
|
const refreshToken = cookies.get("youtube_refresh_token")?.value;
|
||||||
|
if (!refreshToken) {
|
||||||
|
// 401 is expected when user hasn't logged in with YouTube yet
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "No refresh token available" }),
|
||||||
|
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = import.meta.env.PUBLIC_YOUTUBE_CLIENT_ID;
|
||||||
|
const clientSecret = import.meta.env.YOUTUBE_CLIENT_SECRET;
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
console.error("Missing YouTube credentials in environment");
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Server configuration error" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await refreshYoutubeToken(
|
||||||
|
refreshToken,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
);
|
||||||
|
accessToken = tokenData.access_token;
|
||||||
|
refreshTokenOut = tokenData.refresh_token;
|
||||||
|
expiresIn = tokenData.expires_in;
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
cookies.set("youtube_token", accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecure,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: expiresIn || 3600,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshTokenOut) {
|
||||||
|
cookies.set("youtube_refresh_token", refreshTokenOut, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecure,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 365 * 24 * 60 * 60,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return new Response(JSON.stringify({ error: "Unsupported platform" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Token refreshed successfully", {
|
||||||
|
platform,
|
||||||
|
expiresIn,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
accessToken,
|
||||||
|
refreshToken: refreshTokenOut,
|
||||||
|
expiresIn,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
|
let statusCode = 500;
|
||||||
|
if (
|
||||||
|
errorMessage.includes("401") ||
|
||||||
|
errorMessage.includes("Invalid refresh token")
|
||||||
|
) {
|
||||||
|
statusCode = 401;
|
||||||
|
} else if (errorMessage.includes("400")) {
|
||||||
|
statusCode = 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log full details server-side; return only a generic message to the client.
|
||||||
|
console.error("Token refresh failed:", {
|
||||||
|
error: errorMessage,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: statusCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error:
|
||||||
|
statusCode === 401
|
||||||
|
? "Session expired. Please log in again."
|
||||||
|
: "Token refresh failed. Please try again.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: statusCode,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
142
src/pages/api/stream-playlist.ts
Normal file
142
src/pages/api/stream-playlist.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
const GQL_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const channel = url.searchParams.get("channel");
|
||||||
|
if (!channel) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Channel parameter required" }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab a playback access token from Twitch on the server side.
|
||||||
|
// The token is tied to the server's IP, so everything that follows
|
||||||
|
// must also go through the server to avoid 403 errors from the CDN.
|
||||||
|
const gqlRes = await fetch("https://gql.twitch.tv/gql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Client-ID": GQL_CLIENT_ID,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
operationName: "PlaybackAccessToken",
|
||||||
|
extensions: {
|
||||||
|
persistedQuery: {
|
||||||
|
version: 1,
|
||||||
|
sha256Hash:
|
||||||
|
"0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variables: {
|
||||||
|
isLive: true,
|
||||||
|
login: channel,
|
||||||
|
isVod: false,
|
||||||
|
vodID: "",
|
||||||
|
playerType: "embed",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!gqlRes.ok) {
|
||||||
|
throw new Error(`Twitch GQL error: ${gqlRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gqlData = await gqlRes.json();
|
||||||
|
const tokenData = gqlData?.data?.streamPlaybackAccessToken;
|
||||||
|
|
||||||
|
if (!tokenData?.value || !tokenData?.signature) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Stream not available or channel is offline" }),
|
||||||
|
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the master playlist from Twitch's CDN using the token we just got.
|
||||||
|
const usherParams = new URLSearchParams({
|
||||||
|
client_id: GQL_CLIENT_ID,
|
||||||
|
token: tokenData.value,
|
||||||
|
sig: tokenData.signature,
|
||||||
|
allow_source: "true",
|
||||||
|
allow_audio_only: "true",
|
||||||
|
allow_spectre: "false",
|
||||||
|
fast_bread: "true",
|
||||||
|
p: String(Math.floor(Math.random() * 999999)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const usherUrl = `https://usher.ttvnw.net/api/channel/hls/${encodeURIComponent(channel)}.m3u8?${usherParams}`;
|
||||||
|
const masterRes = await fetch(usherUrl);
|
||||||
|
|
||||||
|
if (!masterRes.ok) {
|
||||||
|
if (masterRes.status === 404) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Channel is offline or does not exist" }),
|
||||||
|
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`Usher fetch failed: ${masterRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterText = await masterRes.text();
|
||||||
|
|
||||||
|
// The master playlist lists qualities from best to worst, so the first URL is source quality.
|
||||||
|
const masterLines = masterText.split("\n");
|
||||||
|
let bestVariantUrl = "";
|
||||||
|
for (const line of masterLines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith("http")) {
|
||||||
|
bestVariantUrl = trimmed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestVariantUrl) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Could not parse master playlist" }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the quality-specific sub-playlist. This one contains the actual .ts segment URLs.
|
||||||
|
const variantRes = await fetch(bestVariantUrl);
|
||||||
|
if (!variantRes.ok) {
|
||||||
|
throw new Error(`Variant playlist fetch failed: ${variantRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantText = await variantRes.text();
|
||||||
|
|
||||||
|
// Rewrite every segment URL to go through our proxy so the browser
|
||||||
|
// doesn't run into CORS issues when HLS.js fetches the video chunks.
|
||||||
|
const rewrittenVariant = variantText
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith("http")) {
|
||||||
|
return `/api/stream-segment?url=${encodeURIComponent(trimmed)}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return new Response(rewrittenVariant, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/vnd.apple.mpegurl",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
// Tell HLS.js to always re-fetch — this is a live stream
|
||||||
|
"Cache-Control": "no-cache, no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Stream playlist proxy error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Failed to get playlist",
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
106
src/pages/api/stream-segment.ts
Normal file
106
src/pages/api/stream-segment.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hostnames that this proxy is permitted to fetch on behalf of the client.
|
||||||
|
* Twitch stream segments are served exclusively from these CDN domains.
|
||||||
|
* Any URL pointing elsewhere is rejected to prevent SSRF attacks.
|
||||||
|
*/
|
||||||
|
const ALLOWED_HOSTS = new Set([
|
||||||
|
"video.twitch.tv",
|
||||||
|
"usher.twitchsvc.net",
|
||||||
|
"usher.twitch.tv",
|
||||||
|
"usher.ttvnw.net",
|
||||||
|
"d1m7jfoe9zdc1j.cloudfront.net", // Twitch CloudFront CDN
|
||||||
|
"vod.twitch.tv",
|
||||||
|
// Twitch HLS CDN — segment URLs are served from dynamic subdomains of ttvnw.net
|
||||||
|
// e.g. use14.playlist.ttvnw.net, vod.us-east-2.aws.ttvnw.net
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Allow any *.ttvnw.net subdomain (Twitch's primary HLS CDN)
|
||||||
|
function isAllowedHost(hostname: string): boolean {
|
||||||
|
return ALLOWED_HOSTS.has(hostname) || hostname.endsWith(".ttvnw.net");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const remote = url.searchParams.get("url");
|
||||||
|
if (!remote) {
|
||||||
|
return new Response(JSON.stringify({ error: "url parameter required" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(decodeURIComponent(remote));
|
||||||
|
} catch {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Invalid URL" }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce HTTPS and restrict to known Twitch CDN hostnames.
|
||||||
|
if (parsed.protocol !== "https:") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Only HTTPS URLs are allowed" }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedHost(parsed.hostname)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Disallowed host: ${parsed.hostname}` }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the remote segment/playlist from server-side (avoid browser CORS)
|
||||||
|
const res = await fetch(parsed.href, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mixchat/1.0",
|
||||||
|
Accept: "*/*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
console.error(
|
||||||
|
"Failed fetching remote segment",
|
||||||
|
res.status,
|
||||||
|
parsed.href,
|
||||||
|
text.slice(0, 200),
|
||||||
|
);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Remote fetch failed: ${res.status}` }),
|
||||||
|
{ status: 502, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone response body and forward content-type
|
||||||
|
const contentType =
|
||||||
|
res.headers.get("content-type") || "application/octet-stream";
|
||||||
|
const body = await res.arrayBuffer();
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
// Allow range requests if remote supports it
|
||||||
|
"Accept-Ranges": res.headers.get("accept-ranges") || "bytes",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Stream segment proxy error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Failed to proxy segment",
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
81
src/pages/api/stream-url.ts
Normal file
81
src/pages/api/stream-url.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
const GQL_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const channel = url.searchParams.get("channel");
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Channel parameter required" }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch only the playback access token from Twitch GQL (server-side).
|
||||||
|
// We do NOT fetch the CDN playlist URL here — that must be done by the
|
||||||
|
// browser so that the CDN token is bound to the client's IP, not ours.
|
||||||
|
const gqlRes = await fetch("https://gql.twitch.tv/gql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Client-ID": GQL_CLIENT_ID,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
operationName: "PlaybackAccessToken",
|
||||||
|
extensions: {
|
||||||
|
persistedQuery: {
|
||||||
|
version: 1,
|
||||||
|
sha256Hash:
|
||||||
|
"0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variables: {
|
||||||
|
isLive: true,
|
||||||
|
login: channel,
|
||||||
|
isVod: false,
|
||||||
|
vodID: "",
|
||||||
|
playerType: "embed",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!gqlRes.ok) {
|
||||||
|
throw new Error(`Twitch GQL error: ${gqlRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gqlData = await gqlRes.json();
|
||||||
|
const tokenData = gqlData?.data?.streamPlaybackAccessToken;
|
||||||
|
|
||||||
|
if (!tokenData?.value || !tokenData?.signature) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Stream not available or channel is offline" }),
|
||||||
|
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the token & sig to the client.
|
||||||
|
// The browser will construct the usher.ttvnw.net URL itself so all CDN
|
||||||
|
// segment requests originate from the correct client IP (fixing 403s).
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
token: tokenData.value,
|
||||||
|
sig: tokenData.signature,
|
||||||
|
clientId: GQL_CLIENT_ID,
|
||||||
|
channel,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Stream URL error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Failed to get stream URL",
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
97
src/pages/api/twitch-proxy.ts
Normal file
97
src/pages/api/twitch-proxy.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowlist of Twitch Helix API path prefixes this proxy is permitted to call.
|
||||||
|
* Any path not matching one of these is rejected with 400.
|
||||||
|
* This prevents the endpoint being abused to reach arbitrary external services.
|
||||||
|
*/
|
||||||
|
const ALLOWED_PATH_PREFIXES = [
|
||||||
|
"/helix/chat/emotes/global",
|
||||||
|
"/helix/chat/emotes/user",
|
||||||
|
"/helix/chat/emotes", // covers /helix/chat/emotes?broadcaster_id=...
|
||||||
|
"/helix/chat/badges/global",
|
||||||
|
"/helix/chat/badges", // covers /helix/chat/badges?broadcaster_id=...
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TWITCH_API_BASE = "https://api.twitch.tv";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side proxy for Twitch Helix API calls.
|
||||||
|
*
|
||||||
|
* Purpose: The client no longer needs to store the Twitch access token in
|
||||||
|
* localStorage to make Twitch API calls (emotes, badges). Instead it calls
|
||||||
|
* this endpoint, which reads the token from the httpOnly "twitch_token" cookie
|
||||||
|
* and forwards the request to Twitch on the server side.
|
||||||
|
*
|
||||||
|
* Usage: GET /api/twitch-proxy?path=/helix/chat/emotes/global
|
||||||
|
* GET /api/twitch-proxy?path=/helix/chat/emotes&broadcaster_id=12345
|
||||||
|
* GET /api/twitch-proxy?path=/helix/chat/badges/global
|
||||||
|
*/
|
||||||
|
export const GET: APIRoute = async ({ url, cookies }) => {
|
||||||
|
try {
|
||||||
|
const path = url.searchParams.get("path");
|
||||||
|
if (!path) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "path parameter required" }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the path against the allowlist
|
||||||
|
const isAllowed = ALLOWED_PATH_PREFIXES.some((prefix) =>
|
||||||
|
path === prefix || path.startsWith(prefix + "?") || path.startsWith(prefix + "/"),
|
||||||
|
);
|
||||||
|
if (!isAllowed) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Path not permitted: ${path}` }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the access token from the httpOnly cookie — never from client input.
|
||||||
|
const accessToken = cookies.get("twitch_token")?.value;
|
||||||
|
if (!accessToken) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Not authenticated" }),
|
||||||
|
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
|
||||||
|
if (!clientId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Server configuration error" }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the full Twitch API URL, forwarding any extra query params from the
|
||||||
|
// original request (e.g. broadcaster_id, user_id, after cursor).
|
||||||
|
const forwardParams = new URLSearchParams(url.searchParams);
|
||||||
|
forwardParams.delete("path"); // "path" is our own param, not Twitch's
|
||||||
|
|
||||||
|
const targetUrl = `${TWITCH_API_BASE}${path}${forwardParams.size > 0 ? `?${forwardParams}` : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
headers: {
|
||||||
|
"Client-Id": clientId,
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status: response.status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Twitch proxy error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Proxy request failed" }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
35
src/pages/api/user-info.ts
Normal file
35
src/pages/api/user-info.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { getTwitchUserById } from "../../lib/twitch";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const userId = url.searchParams.get("userId");
|
||||||
|
const authHeader = request.headers.get("Authorization");
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return new Response("User ID is required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
return new Response("Authorization header is required", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = authHeader.replace("Bearer ", "");
|
||||||
|
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await getTwitchUserById(accessToken, clientId, userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return new Response("User not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(user), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch user info:", error);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
154
src/pages/api/youtube-chat.ts
Normal file
154
src/pages/api/youtube-chat.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/youtube-chat
|
||||||
|
*
|
||||||
|
* Sends a message to a YouTube live chat.
|
||||||
|
*
|
||||||
|
* Body (JSON):
|
||||||
|
* videoId – YouTube video ID of the live stream
|
||||||
|
* message – Text message to send
|
||||||
|
* accessToken – YouTube OAuth2 access token (user must have granted live-chat scope)
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { videoId, message, accessToken } = body as {
|
||||||
|
videoId?: string;
|
||||||
|
message?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!videoId || !message || !accessToken) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "videoId, message, and accessToken are required",
|
||||||
|
}),
|
||||||
|
{ status: 400, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = import.meta.env.PUBLIC_YOUTUBE_API_KEY;
|
||||||
|
|
||||||
|
// 1. Get the liveChatId from the video
|
||||||
|
const liveChatResult = await getLiveChatId(videoId, accessToken, apiKey);
|
||||||
|
if (!liveChatResult.liveChatId) {
|
||||||
|
console.error("getLiveChatId failed:", liveChatResult.debugInfo);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error:
|
||||||
|
liveChatResult.debugInfo ||
|
||||||
|
"Could not find live chat for this video. The stream may not be live.",
|
||||||
|
}),
|
||||||
|
{ status: 422, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveChatId = liveChatResult.liveChatId;
|
||||||
|
|
||||||
|
// 2. Send the message
|
||||||
|
const sendRes = await fetch(
|
||||||
|
"https://www.googleapis.com/youtube/v3/liveChat/messages?part=snippet",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
snippet: {
|
||||||
|
liveChatId,
|
||||||
|
type: "textMessageEvent",
|
||||||
|
textMessageDetails: {
|
||||||
|
messageText: message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sendRes.ok) {
|
||||||
|
const errData = await sendRes.json().catch(() => ({}));
|
||||||
|
const errMsg =
|
||||||
|
errData?.error?.message || `YouTube API error: ${sendRes.status}`;
|
||||||
|
console.error("YouTube send message error:", errData);
|
||||||
|
return new Response(JSON.stringify({ error: errMsg }), {
|
||||||
|
status: sendRes.status,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendRes.json();
|
||||||
|
return new Response(JSON.stringify({ success: true, id: result.id }), {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("youtube-chat API error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: error instanceof Error ? error.message : "Internal server error",
|
||||||
|
}),
|
||||||
|
{ status: 500, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the liveChatId for a given YouTube video.
|
||||||
|
* Uses the API key for video lookup (public data), falls back to access token.
|
||||||
|
*/
|
||||||
|
async function getLiveChatId(
|
||||||
|
videoId: string,
|
||||||
|
accessToken: string,
|
||||||
|
apiKey?: string,
|
||||||
|
): Promise<{ liveChatId: string | null; debugInfo: string }> {
|
||||||
|
// Try with API key first (more reliable for public video data), then fall back to OAuth token
|
||||||
|
const urlBase = `https://www.googleapis.com/youtube/v3/videos?part=liveStreamingDetails&id=${encodeURIComponent(videoId)}`;
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
if (apiKey) {
|
||||||
|
res = await fetch(`${urlBase}&key=${apiKey}`);
|
||||||
|
} else {
|
||||||
|
res = await fetch(urlBase, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.text().catch(() => "");
|
||||||
|
return {
|
||||||
|
liveChatId: null,
|
||||||
|
debugInfo: `YouTube videos.list returned ${res.status}: ${errBody.slice(0, 200)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.items || data.items.length === 0) {
|
||||||
|
return {
|
||||||
|
liveChatId: null,
|
||||||
|
debugInfo: `No video found for ID "${videoId}". The video may not exist or may be private.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveDetails = data.items[0]?.liveStreamingDetails;
|
||||||
|
if (!liveDetails) {
|
||||||
|
return {
|
||||||
|
liveChatId: null,
|
||||||
|
debugInfo: `Video "${videoId}" has no liveStreamingDetails. It may not be a live stream.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatId = liveDetails.activeLiveChatId;
|
||||||
|
if (!chatId) {
|
||||||
|
return {
|
||||||
|
liveChatId: null,
|
||||||
|
debugInfo: `Video "${videoId}" has liveStreamingDetails but no activeLiveChatId. The stream may have ended.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { liveChatId: chatId, debugInfo: "" };
|
||||||
|
}
|
||||||
206
src/pages/api/youtube-emotes.ts
Normal file
206
src/pages/api/youtube-emotes.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/youtube-emotes?videoId=xxx
|
||||||
|
*
|
||||||
|
* Fetches the channel's custom emojis (membership emotes) by scraping the
|
||||||
|
* same YouTube page that the youtube-chat library uses internally.
|
||||||
|
*
|
||||||
|
* YouTube's official Data API v3 does NOT expose channel custom emojis, so
|
||||||
|
* we extract them from ytInitialData embedded in the live-chat frame HTML,
|
||||||
|
* which is the same source the Innertube API returns to the library.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface YTEmote {
|
||||||
|
name: string; // shortcut / emojiText, e.g. ":channelName_emote1:"
|
||||||
|
url: string; // CDN image URL
|
||||||
|
isCustomEmoji: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
const videoId = url.searchParams.get('videoId');
|
||||||
|
|
||||||
|
if (!videoId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'videoId query parameter is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emotes = await fetchCustomEmojis(videoId);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true, videoId, emotes }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
// Cache for 5 minutes — emotes don't change mid-stream
|
||||||
|
'Cache-Control': 'public, max-age=300',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('YouTube emotes fetch error:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch emotes',
|
||||||
|
emotes: [],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
// Return 200 with empty list instead of 500 — missing emotes is non-fatal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchCustomEmojis(videoId: string): Promise<YTEmote[]> {
|
||||||
|
// Step 1: Fetch the watch page — contains ytInitialData with customEmojis
|
||||||
|
const watchPageUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||||||
|
const pageRes = await fetch(watchPageUrl, {
|
||||||
|
headers: {
|
||||||
|
// Mimic a browser so YouTube returns the full JS-embedded data
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
||||||
|
'(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pageRes.ok) {
|
||||||
|
throw new Error(`Failed to fetch YouTube watch page: ${pageRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await pageRes.text();
|
||||||
|
|
||||||
|
// Step 2: Extract ytInitialData JSON from the page
|
||||||
|
// YouTube embeds it as: var ytInitialData = {...};
|
||||||
|
const initDataMatch = html.match(/var ytInitialData\s*=\s*(\{.+?\});\s*(?:var |<\/script>)/s);
|
||||||
|
if (!initDataMatch) {
|
||||||
|
// Try the live-chat iframe URL instead, which also has customEmojis
|
||||||
|
return fetchCustomEmojisFromChatFrame(videoId, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ytInitialData: any;
|
||||||
|
try {
|
||||||
|
ytInitialData = JSON.parse(initDataMatch[1]);
|
||||||
|
} catch {
|
||||||
|
return fetchCustomEmojisFromChatFrame(videoId, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Walk ytInitialData to find customEmojis.
|
||||||
|
// They live at: contents.liveChatRenderer.customEmojis (in chat embed data)
|
||||||
|
// or inside engagementPanels → liveChatRenderer
|
||||||
|
const emotes = extractCustomEmojisFromInitData(ytInitialData);
|
||||||
|
if (emotes.length > 0) return emotes;
|
||||||
|
|
||||||
|
// Fallback: try the dedicated live-chat frame
|
||||||
|
return fetchCustomEmojisFromChatFrame(videoId, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: fetch https://www.youtube.com/live_chat?v=xxx which is the
|
||||||
|
* iframe YouTube embeds for chat. Its ytInitialData has a liveChatRenderer
|
||||||
|
* with customEmojis at the top level.
|
||||||
|
*/
|
||||||
|
async function fetchCustomEmojisFromChatFrame(
|
||||||
|
videoId: string,
|
||||||
|
_watchHtml?: string
|
||||||
|
): Promise<YTEmote[]> {
|
||||||
|
const chatFrameUrl = `https://www.youtube.com/live_chat?v=${videoId}&embed_domain=www.youtube.com`;
|
||||||
|
const res = await fetch(chatFrameUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
||||||
|
'(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return [];
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
// ytInitialData is embedded the same way in the chat frame
|
||||||
|
const match = html.match(/var ytInitialData\s*=\s*(\{.+?\});\s*(?:var |<\/script>)/s);
|
||||||
|
if (!match) return [];
|
||||||
|
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(match[1]);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractCustomEmojisFromInitData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively finds any `customEmojis` array in ytInitialData and converts
|
||||||
|
* each entry to our YTEmote shape.
|
||||||
|
*
|
||||||
|
* YouTube's customEmoji entries look like:
|
||||||
|
* {
|
||||||
|
* emojiId: "UgkX...",
|
||||||
|
* shortcuts: [":channelName_emote1:"],
|
||||||
|
* searchTerms: [...],
|
||||||
|
* image: { thumbnails: [{ url, width, height }, ...], accessibility: ... },
|
||||||
|
* isCustomEmoji: true
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
function extractCustomEmojisFromInitData(data: any): YTEmote[] {
|
||||||
|
// BFS search for a `customEmojis` key anywhere in the tree
|
||||||
|
const queue: any[] = [data];
|
||||||
|
const results: YTEmote[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const node = queue.shift();
|
||||||
|
if (!node || typeof node !== 'object') continue;
|
||||||
|
|
||||||
|
if (Array.isArray(node.customEmojis)) {
|
||||||
|
for (const emoji of node.customEmojis) {
|
||||||
|
const emote = parseCustomEmoji(emoji);
|
||||||
|
if (emote && !seen.has(emote.name)) {
|
||||||
|
seen.add(emote.name);
|
||||||
|
results.push(emote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Don't stop — there might be more (e.g. multiple renderers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue child nodes (limit depth to avoid huge traversals)
|
||||||
|
for (const key of Object.keys(node)) {
|
||||||
|
const child = node[key];
|
||||||
|
if (child && typeof child === 'object') {
|
||||||
|
queue.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCustomEmoji(emoji: any): YTEmote | null {
|
||||||
|
if (!emoji || typeof emoji !== 'object') return null;
|
||||||
|
|
||||||
|
// Pick the best image URL: prefer a mid-size thumbnail
|
||||||
|
const thumbnails: any[] = emoji.image?.thumbnails ?? [];
|
||||||
|
if (thumbnails.length === 0) return null;
|
||||||
|
|
||||||
|
// Sort ascending by width and pick the smallest that is >= 32px, else the largest
|
||||||
|
const sorted = [...thumbnails].sort((a, b) => (a.width ?? 0) - (b.width ?? 0));
|
||||||
|
const preferred = sorted.find((t) => (t.width ?? 0) >= 32) ?? sorted[sorted.length - 1];
|
||||||
|
const url: string = preferred?.url ?? '';
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
// Name: prefer the shortcut code (e.g. ":channelName_hi:"), fall back to emojiId
|
||||||
|
const shortcuts: string[] = emoji.shortcuts ?? [];
|
||||||
|
const name = shortcuts[0] || emoji.emojiId || '';
|
||||||
|
if (!name) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
isCustomEmoji: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
359
src/pages/api/youtube-live.ts
Normal file
359
src/pages/api/youtube-live.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
// Simple in-memory cache and in-flight dedupe to avoid excessive YouTube API calls.
|
||||||
|
|
||||||
|
const CHANNEL_CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
|
||||||
|
const LIVE_CACHE_TTL = 1000 * 60 * 10; // 10 minutes
|
||||||
|
|
||||||
|
const channelResolveCache = new Map<
|
||||||
|
string,
|
||||||
|
{ channelId: string; expires: number }
|
||||||
|
>();
|
||||||
|
const liveVideoCache = new Map<
|
||||||
|
string,
|
||||||
|
{ videoId: string | null; expires: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const inFlightChannelResolves = new Map<string, Promise<string | null>>();
|
||||||
|
const inFlightLiveFinds = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
// Resolve a YouTube channel URL to its active live stream video ID.
|
||||||
|
// We use the Data API v3 and some local caching to save on quota.
|
||||||
|
export const GET: APIRoute = async ({ url, request }) => {
|
||||||
|
const headers = {
|
||||||
|
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "public, max-age=60, s-maxage=600",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channelUrl = url.searchParams.get("channelUrl");
|
||||||
|
|
||||||
|
// Check Authorization header for accessToken
|
||||||
|
const authHeader = url.searchParams.get("accessToken") || (typeof request !== 'undefined' ? (request as any).headers.get("Authorization") : null);
|
||||||
|
const accessToken = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : authHeader;
|
||||||
|
|
||||||
|
if (!channelUrl) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "channelUrl parameter is required" }),
|
||||||
|
{ status: 400, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = import.meta.env.PUBLIC_YOUTUBE_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "YouTube API key not configured on the server",
|
||||||
|
}),
|
||||||
|
{ status: 500, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Resolve the channel URL to a channel ID
|
||||||
|
const channelId = await resolveChannelId(channelUrl, apiKey);
|
||||||
|
if (!channelId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Could not resolve YouTube channel from the given URL",
|
||||||
|
}),
|
||||||
|
{ status: 404, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If we have an access token, check if it belongs to this channel
|
||||||
|
// This allows finding unlisted or private streams for the logged-in user.
|
||||||
|
if (accessToken) {
|
||||||
|
try {
|
||||||
|
const myChannelId = await getMyChannelId(accessToken);
|
||||||
|
if (myChannelId === channelId) {
|
||||||
|
const liveId = await findMyLiveVideoId(accessToken);
|
||||||
|
if (liveId) {
|
||||||
|
return new Response(JSON.stringify({ videoId: liveId, channelId }), {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Auth-based live check failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Search for an active live stream on that channel (public)
|
||||||
|
const videoId = await findLiveVideoId(channelId, apiKey);
|
||||||
|
if (!videoId) {
|
||||||
|
// Return 200 with null videoId instead of 404 to avoid console errors.
|
||||||
|
// 404 is still technically correct (the live stream resource isn't there),
|
||||||
|
// but it causes noise in browser developer tools for periodic polling.
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
videoId: null,
|
||||||
|
error: "No active live stream found for this channel",
|
||||||
|
channelId,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ videoId, channelId }), {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("youtube-live API error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: error instanceof Error ? error.message : "Internal server error",
|
||||||
|
}),
|
||||||
|
{ status: 500, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses a YouTube access token to find the authorized user's channel ID.
|
||||||
|
*/
|
||||||
|
async function getMyChannelId(accessToken: string): Promise<string | null> {
|
||||||
|
const res = await fetch(
|
||||||
|
"https://www.googleapis.com/youtube/v3/channels?part=id&mine=true",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
return data.items[0].id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses a YouTube access token to find the currently active live broadcast for the authorized user.
|
||||||
|
*/
|
||||||
|
async function findMyLiveVideoId(accessToken: string): Promise<string | null> {
|
||||||
|
const res = await fetch(
|
||||||
|
"https://www.googleapis.com/youtube/v3/liveBroadcasts?part=id&broadcastStatus=active&mine=true",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) throw new Error("Unauthorized");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
// Return the video ID (the broadcast ID is identical to the video ID)
|
||||||
|
return data.items[0].id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Helper to resolve different styles of YouTube URLs (handles, channel IDs, etc)
|
||||||
|
// and return the core channel ID.
|
||||||
|
async function resolveChannelId(
|
||||||
|
channelUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Return cached mapping if available
|
||||||
|
const cached = channelResolveCache.get(channelUrl);
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
return cached.channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate concurrent resolves for the same URL
|
||||||
|
if (inFlightChannelResolves.has(channelUrl)) {
|
||||||
|
return await inFlightChannelResolves.get(channelUrl)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(channelUrl);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = parsed.pathname; // e.g. /@lunateac or /channel/UCxyz
|
||||||
|
|
||||||
|
// Direct channel ID
|
||||||
|
if (pathname.startsWith("/channel/")) {
|
||||||
|
const id = pathname.replace("/channel/", "").split("/")[0];
|
||||||
|
channelResolveCache.set(channelUrl, {
|
||||||
|
channelId: id,
|
||||||
|
expires: now + CHANNEL_CACHE_TTL,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle-based URL (@handle)
|
||||||
|
if (pathname.startsWith("/@")) {
|
||||||
|
const handle = pathname.replace("/@", "").split("/")[0];
|
||||||
|
const id = await resolveHandleViaApi(handle, apiKey);
|
||||||
|
if (id)
|
||||||
|
channelResolveCache.set(channelUrl, {
|
||||||
|
channelId: id,
|
||||||
|
expires: now + CHANNEL_CACHE_TTL,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /c/CustomName or /user/Username – try forUsername first, then search
|
||||||
|
if (pathname.startsWith("/c/") || pathname.startsWith("/user/")) {
|
||||||
|
const name = pathname.split("/")[2];
|
||||||
|
const id = await resolveByUsernameOrSearch(name, apiKey);
|
||||||
|
if (id)
|
||||||
|
channelResolveCache.set(channelUrl, {
|
||||||
|
channelId: id,
|
||||||
|
expires: now + CHANNEL_CACHE_TTL,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: treat last segment as possible handle
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
if (segments.length > 0) {
|
||||||
|
const id = await resolveHandleViaApi(
|
||||||
|
segments[segments.length - 1],
|
||||||
|
apiKey,
|
||||||
|
);
|
||||||
|
if (id)
|
||||||
|
channelResolveCache.set(channelUrl, {
|
||||||
|
channelId: id,
|
||||||
|
expires: now + CHANNEL_CACHE_TTL,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
inFlightChannelResolves.set(channelUrl, promise);
|
||||||
|
try {
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
inFlightChannelResolves.delete(channelUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveHandleViaApi(
|
||||||
|
handle: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
// YouTube Data API v3 – channels.list with forHandle
|
||||||
|
const res = await fetch(
|
||||||
|
`https://www.googleapis.com/youtube/v3/channels?part=id&forHandle=${encodeURIComponent(handle)}&key=${apiKey}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`YouTube API channels.list forHandle failed: ${res.status}`);
|
||||||
|
// Fallback to search
|
||||||
|
return resolveByUsernameOrSearch(handle, apiKey);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
return data.items[0].id;
|
||||||
|
}
|
||||||
|
// Fallback to search
|
||||||
|
return resolveByUsernameOrSearch(handle, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveByUsernameOrSearch(
|
||||||
|
name: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
// Try forUsername
|
||||||
|
const res = await fetch(
|
||||||
|
`https://www.googleapis.com/youtube/v3/channels?part=id&forUsername=${encodeURIComponent(name)}&key=${apiKey}`,
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
return data.items[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: search for the channel name
|
||||||
|
const searchRes = await fetch(
|
||||||
|
`https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(name)}&type=channel&maxResults=1&key=${apiKey}`,
|
||||||
|
);
|
||||||
|
if (searchRes.ok) {
|
||||||
|
const searchData = await searchRes.json();
|
||||||
|
if (searchData.items && searchData.items.length > 0) {
|
||||||
|
return searchData.items[0].snippet.channelId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a channel actually has a live stream running right now
|
||||||
|
async function findLiveVideoId(
|
||||||
|
channelId: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = liveVideoCache.get(channelId);
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
return cached.videoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe concurrent lookups
|
||||||
|
if (inFlightLiveFinds.has(channelId)) {
|
||||||
|
return await inFlightLiveFinds.get(channelId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://www.googleapis.com/youtube/v3/search?part=id&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`YouTube search for live video failed: ${res.status}`);
|
||||||
|
// Cache negative result for a short period to avoid thundering retry
|
||||||
|
liveVideoCache.set(channelId, {
|
||||||
|
videoId: null,
|
||||||
|
expires: now + LIVE_CACHE_TTL,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
const vid = data.items[0].id.videoId;
|
||||||
|
liveVideoCache.set(channelId, {
|
||||||
|
videoId: vid,
|
||||||
|
expires: now + LIVE_CACHE_TTL,
|
||||||
|
});
|
||||||
|
return vid;
|
||||||
|
}
|
||||||
|
// No live video found — cache null for a short period
|
||||||
|
liveVideoCache.set(channelId, {
|
||||||
|
videoId: null,
|
||||||
|
expires: now + LIVE_CACHE_TTL,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
inFlightLiveFinds.set(channelId, promise);
|
||||||
|
try {
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
inFlightLiveFinds.delete(channelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
243
src/pages/api/youtube-stream-chat.ts
Normal file
243
src/pages/api/youtube-stream-chat.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { LiveChat } from 'youtube-chat';
|
||||||
|
|
||||||
|
// Handle YouTube chat via the youtube-chat library.
|
||||||
|
// We cache the chat connection in a global to avoid firing it up on every request.
|
||||||
|
|
||||||
|
interface MessagePart {
|
||||||
|
type: 'text' | 'emoji';
|
||||||
|
text?: string; // for text parts
|
||||||
|
url?: string; // for emoji/emote parts
|
||||||
|
alt?: string; // for emoji/emote parts
|
||||||
|
emojiText?: string; // shortcode like ":slightly_smiling_face:"
|
||||||
|
isCustomEmoji?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
authorAvatar?: string;
|
||||||
|
badges?: Record<string, string>;
|
||||||
|
parts: MessagePart[];
|
||||||
|
timestamp: Date;
|
||||||
|
superchat?: {
|
||||||
|
amount: string;
|
||||||
|
color: string;
|
||||||
|
sticker?: {
|
||||||
|
url: string;
|
||||||
|
alt: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YTEmoteEntry {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
isCustomEmoji: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global map to store active LiveChat instances and their message caches
|
||||||
|
const activeLiveChats = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
chat: LiveChat;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
/** Deduplicated emote registry: name → entry */
|
||||||
|
emoteMap: Map<string, YTEmoteEntry>;
|
||||||
|
lastUpdate: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
const videoId = url.searchParams.get('videoId');
|
||||||
|
|
||||||
|
if (!videoId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'videoId query parameter is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let chatSession = activeLiveChats.get(videoId);
|
||||||
|
|
||||||
|
// Create a new LiveChat session if one doesn't exist
|
||||||
|
if (!chatSession) {
|
||||||
|
// Use a lock-like mechanism to prevent duplicate sessions from concurrent requests
|
||||||
|
const chat = new LiveChat({
|
||||||
|
liveId: videoId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: ChatMessage[] = [];
|
||||||
|
const emoteMap = new Map<string, YTEmoteEntry>();
|
||||||
|
|
||||||
|
// Handle incoming messages
|
||||||
|
chat.on('chat', (chatItem: any) => {
|
||||||
|
// Use the library's message ID if available, otherwise generate a stable one
|
||||||
|
// chatItem.id is the actual unique ID from YouTube if the library provides it.
|
||||||
|
const messageId = chatItem.id || `${chatItem.author?.channelId || 'anon'}-${chatItem.timestamp?.getTime() || Date.now()}`;
|
||||||
|
|
||||||
|
// Deduplicate messages by ID to prevent duplicates if the scraper re-emits them
|
||||||
|
if (messages.some(m => m.id === messageId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert raw message parts into our typed MessagePart array.
|
||||||
|
const parts: MessagePart[] = (chatItem.message || []).map((msg: any) => {
|
||||||
|
if (msg.url) {
|
||||||
|
const name = msg.emojiText || msg.alt || msg.url;
|
||||||
|
if (!emoteMap.has(name)) {
|
||||||
|
emoteMap.set(name, {
|
||||||
|
name,
|
||||||
|
url: msg.url,
|
||||||
|
isCustomEmoji: !!msg.isCustomEmoji,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'emoji' as const,
|
||||||
|
url: msg.url,
|
||||||
|
alt: msg.alt || msg.emojiText || '',
|
||||||
|
emojiText: msg.emojiText || '',
|
||||||
|
isCustomEmoji: !!msg.isCustomEmoji,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: 'text' as const, text: msg.text ?? String(msg) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: messageId,
|
||||||
|
author: chatItem.author.name || 'Anonymous',
|
||||||
|
authorAvatar: chatItem.author.thumbnail?.url,
|
||||||
|
badges: chatItem.author.badge ? { [chatItem.author.badge.title]: chatItem.author.badge.thumbnail?.url } : undefined,
|
||||||
|
parts,
|
||||||
|
timestamp: chatItem.timestamp || new Date(),
|
||||||
|
superchat: chatItem.superchat ? {
|
||||||
|
amount: chatItem.superchat.amount,
|
||||||
|
color: chatItem.superchat.color,
|
||||||
|
sticker: chatItem.superchat.sticker ? {
|
||||||
|
url: chatItem.superchat.sticker.url,
|
||||||
|
alt: chatItem.superchat.sticker.alt,
|
||||||
|
} : undefined,
|
||||||
|
} : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
messages.push(message);
|
||||||
|
// Keep only last 200 messages
|
||||||
|
if (messages.length > 200) {
|
||||||
|
messages.shift();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
chat.on('error', (err: any) => {
|
||||||
|
console.error('YouTube LiveChat error:', err);
|
||||||
|
// Clean up failed session after 30 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (activeLiveChats.get(videoId)?.chat === chat) {
|
||||||
|
activeLiveChats.delete(videoId);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle stream end
|
||||||
|
chat.on('end', () => {
|
||||||
|
console.log('YouTube stream ended for videoId:', videoId);
|
||||||
|
if (activeLiveChats.get(videoId)?.chat === chat) {
|
||||||
|
activeLiveChats.delete(videoId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the chat connection
|
||||||
|
const ok = await chat.start();
|
||||||
|
if (!ok) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to connect to YouTube chat' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
chatSession = {
|
||||||
|
chat,
|
||||||
|
messages,
|
||||||
|
emoteMap,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if another request beat us to it while we were waiting for chat.start()
|
||||||
|
const existing = activeLiveChats.get(videoId);
|
||||||
|
if (existing) {
|
||||||
|
// If an existing session exists, stop this one to avoid double scraping
|
||||||
|
chat.stop();
|
||||||
|
chatSession = existing;
|
||||||
|
} else {
|
||||||
|
activeLiveChats.set(videoId, chatSession);
|
||||||
|
console.log(`Created LiveChat session for videoId: ${videoId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get channel emotes in the background so autocomplete is snappy.
|
||||||
|
// This pulls from the initial data scrape so users don't have to wait for them to appear in chat.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Build an absolute URL from the current request's origin
|
||||||
|
const origin = url.origin;
|
||||||
|
const emoteRes = await fetch(
|
||||||
|
`${origin}/api/youtube-emotes?videoId=${encodeURIComponent(videoId)}`
|
||||||
|
);
|
||||||
|
if (emoteRes.ok) {
|
||||||
|
const emoteData = await emoteRes.json();
|
||||||
|
if (Array.isArray(emoteData.emotes)) {
|
||||||
|
const session = activeLiveChats.get(videoId);
|
||||||
|
if (session) {
|
||||||
|
for (const e of emoteData.emotes) {
|
||||||
|
if (e.name && e.url && !session.emoteMap.has(e.name)) {
|
||||||
|
session.emoteMap.set(e.name, {
|
||||||
|
name: e.name,
|
||||||
|
url: e.url,
|
||||||
|
isCustomEmoji: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Pre-loaded ${emoteData.emotes.length} channel emotes for videoId: ${videoId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal — emotes will still accumulate from chat messages
|
||||||
|
console.warn('Failed to pre-load YouTube emotes:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last access time
|
||||||
|
chatSession.lastUpdate = Date.now();
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
videoId,
|
||||||
|
messages: chatSession.messages,
|
||||||
|
messageCount: chatSession.messages.length,
|
||||||
|
// All unique emotes/emoji seen so far across all messages
|
||||||
|
emotes: Array.from(chatSession.emoteMap.values()),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('YouTube stream chat API error:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: error instanceof Error ? error.message : 'Internal server error',
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
142
src/pages/auth/twitch/callback.ts
Normal file
142
src/pages/auth/twitch/callback.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { getTwitchAccessToken, getTwitchUser } from "../../../lib/twitch";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url, cookies }) => {
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
const returnedState = url.searchParams.get("state");
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return new Response("Authorization code not found", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The state value is validated client-side (in the inline script) because
|
||||||
|
// sessionStorage is only accessible in the browser. We still require it to
|
||||||
|
// be present on the server so a stateless request is always rejected.
|
||||||
|
if (!returnedState) {
|
||||||
|
return new Response("Missing state parameter", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isDev = import.meta.env.MODE === 'development';
|
||||||
|
const isSecure = !isDev;
|
||||||
|
|
||||||
|
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
|
||||||
|
const clientSecret = import.meta.env.TWITCH_CLIENT_SECRET;
|
||||||
|
const redirectUri = import.meta.env.PUBLIC_TWITCH_REDIRECT_URI;
|
||||||
|
|
||||||
|
const tokenData = await getTwitchAccessToken(
|
||||||
|
code,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
redirectUri,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await getTwitchUser(tokenData.accessToken, clientId);
|
||||||
|
|
||||||
|
// Store token in secure cookie
|
||||||
|
cookies.set("twitch_token", tokenData.accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecure,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: tokenData.expiresIn || 3600,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tokenData.refreshToken) {
|
||||||
|
cookies.set("twitch_refresh_token", tokenData.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecure,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely serialize all values with JSON.stringify so that special characters
|
||||||
|
// (quotes, backslashes, newlines, etc.) in any user-controlled field can never
|
||||||
|
// break out of the JavaScript context and execute arbitrary code.
|
||||||
|
const sessionPayload = JSON.stringify({
|
||||||
|
platform: "twitch",
|
||||||
|
userId: user.userId,
|
||||||
|
displayName: user.displayName,
|
||||||
|
username: user.login,
|
||||||
|
profileImageUrl: user.profileImageUrl,
|
||||||
|
accessToken: tokenData.accessToken,
|
||||||
|
refreshToken: tokenData.refreshToken ?? "",
|
||||||
|
expiresAt: Date.now() + ((tokenData.expiresIn ?? 3600) * 1000),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
// Pass the returned state value safely so the browser can verify it.
|
||||||
|
const returnedStateJson = JSON.stringify(returnedState);
|
||||||
|
|
||||||
|
// Return HTML that stores session in localStorage, posts message to opener, and redirects
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Twitch Auth</title></head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// --- CSRF state validation ---
|
||||||
|
var expectedState = sessionStorage.getItem('twitch_oauth_state');
|
||||||
|
var returnedState = ${returnedStateJson};
|
||||||
|
sessionStorage.removeItem('twitch_oauth_state');
|
||||||
|
|
||||||
|
if (!expectedState || expectedState !== returnedState) {
|
||||||
|
document.body.innerText = 'Authentication failed: invalid state. Possible CSRF attack.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// --- End CSRF check ---
|
||||||
|
|
||||||
|
var session = ${sessionPayload};
|
||||||
|
|
||||||
|
try {
|
||||||
|
var sessions = JSON.parse(localStorage.getItem('mixchat_sessions') || '{}');
|
||||||
|
// Strip accessToken: it lives only in memory (React state) and is
|
||||||
|
// repopulated on reload via useTokenRefresh + the httpOnly cookie.
|
||||||
|
var persisted = Object.assign({}, session);
|
||||||
|
delete persisted.accessToken;
|
||||||
|
sessions.twitch = persisted;
|
||||||
|
localStorage.setItem('mixchat_sessions', JSON.stringify(sessions));
|
||||||
|
} catch (e) { /* storage unavailable */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (window.opener && window.opener.postMessage) {
|
||||||
|
window.opener.postMessage({ type: 'mixchat_session', session: session }, window.location.origin);
|
||||||
|
}
|
||||||
|
} catch (e) { /* cross-origin opener */ }
|
||||||
|
|
||||||
|
// If opened as a popup, close after notifying opener; otherwise redirect
|
||||||
|
if (window.opener) {
|
||||||
|
try { window.close(); } catch (e) {}
|
||||||
|
// fallback redirect in case close() is blocked
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
<\/script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Twitch OAuth callback error:", error);
|
||||||
|
// Return a generic error page — never reflect raw error messages to the client.
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Auth Error</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Authentication Failed</h1>
|
||||||
|
<p>An error occurred during authentication. Please try again.</p>
|
||||||
|
<a href="/">Back to home</a>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{ status: 500, headers: { "Content-Type": "text/html" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
169
src/pages/auth/youtube/callback.ts
Normal file
169
src/pages/auth/youtube/callback.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { getYoutubeAccessToken, getYoutubeUser } from "../../../lib/youtube";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url, cookies }) => {
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
const returnedState = url.searchParams.get("state");
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return new Response("Authorization code not found", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require the state parameter to be present on every callback request.
|
||||||
|
if (!returnedState) {
|
||||||
|
return new Response("Missing state parameter", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isDev = import.meta.env.MODE === 'development';
|
||||||
|
const isSecure = !isDev;
|
||||||
|
|
||||||
|
const clientId = import.meta.env.PUBLIC_YOUTUBE_CLIENT_ID;
|
||||||
|
const clientSecret = import.meta.env.YOUTUBE_CLIENT_SECRET;
|
||||||
|
const redirectUri = import.meta.env.PUBLIC_YOUTUBE_REDIRECT_URI;
|
||||||
|
|
||||||
|
const tokenData = await getYoutubeAccessToken(
|
||||||
|
code,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
redirectUri,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await getYoutubeUser(tokenData.access_token);
|
||||||
|
|
||||||
|
// Store token in secure cookie. Align settings with the refresh endpoint
|
||||||
|
// so the server can read the refresh token later and refresh seamlessly.
|
||||||
|
cookies.set("youtube_token", tokenData.access_token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecure,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: tokenData.expires_in || 3600,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tokenData.refresh_token) {
|
||||||
|
cookies.set("youtube_refresh_token", tokenData.refresh_token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecure,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 365 * 24 * 60 * 60, // 365 days
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely serialize all values with JSON.stringify so that special characters
|
||||||
|
// (quotes, backslashes, newlines, etc.) in any user-controlled field can never
|
||||||
|
// break out of the JavaScript context and execute arbitrary code.
|
||||||
|
const sessionPayload = JSON.stringify({
|
||||||
|
platform: "youtube",
|
||||||
|
userId: user.userId,
|
||||||
|
displayName: user.displayName,
|
||||||
|
profileImageUrl: user.profileImageUrl,
|
||||||
|
accessToken: tokenData.access_token,
|
||||||
|
// Only include refreshToken when Google actually returns one.
|
||||||
|
// An empty string would be falsy and cause useTokenRefresh to skip
|
||||||
|
// the httpOnly-cookie-based token refresh on page reload.
|
||||||
|
...(tokenData.refresh_token ? { refreshToken: tokenData.refresh_token } : {}),
|
||||||
|
expiresAt: Date.now() + ((tokenData.expires_in ?? 3600) * 1000),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
// Pass the returned state value safely so the browser can verify it.
|
||||||
|
const returnedStateJson = JSON.stringify(returnedState);
|
||||||
|
|
||||||
|
// Return HTML that posts the session to the opener (popup flow) and
|
||||||
|
// falls back to a localStorage + redirect approach when opened as a
|
||||||
|
// full-page redirect (e.g. popup blocked).
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>YouTube Auth</title></head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var returnedState = ${returnedStateJson};
|
||||||
|
|
||||||
|
// --- CSRF state validation ---
|
||||||
|
// In a popup flow the sessionStorage is scoped to the popup browsing context.
|
||||||
|
// After navigating through an external OAuth provider (Google) the popup's
|
||||||
|
// sessionStorage may no longer contain the key that was set before the navigation.
|
||||||
|
// Try to read the expected state from the opener's sessionStorage first
|
||||||
|
// (same-origin access is allowed), then fall back to the local copy.
|
||||||
|
var expectedState = null;
|
||||||
|
try {
|
||||||
|
if (window.opener && window.opener.sessionStorage) {
|
||||||
|
expectedState = window.opener.sessionStorage.getItem('youtube_oauth_state');
|
||||||
|
window.opener.sessionStorage.removeItem('youtube_oauth_state');
|
||||||
|
}
|
||||||
|
} catch (e) { /* opener may be cross-origin or inaccessible */ }
|
||||||
|
|
||||||
|
if (!expectedState) {
|
||||||
|
// Fallback: redirect flow — state lives in this window's sessionStorage.
|
||||||
|
expectedState = sessionStorage.getItem('youtube_oauth_state');
|
||||||
|
sessionStorage.removeItem('youtube_oauth_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedState || expectedState !== returnedState) {
|
||||||
|
document.body.innerText = 'Authentication failed: invalid state. Possible CSRF attack.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// --- End CSRF check ---
|
||||||
|
|
||||||
|
var session = ${sessionPayload};
|
||||||
|
|
||||||
|
try {
|
||||||
|
var sessions = JSON.parse(localStorage.getItem('mixchat_sessions') || '{}');
|
||||||
|
// Strip accessToken: it lives only in memory (React state) and is
|
||||||
|
// repopulated on reload via useTokenRefresh + the httpOnly cookie.
|
||||||
|
var persisted = Object.assign({}, session);
|
||||||
|
delete persisted.accessToken;
|
||||||
|
sessions.youtube = persisted;
|
||||||
|
localStorage.setItem('mixchat_sessions', JSON.stringify(sessions));
|
||||||
|
} catch (e) { /* storage unavailable */ }
|
||||||
|
|
||||||
|
// Signal the parent window via postMessage (if opener reference is alive)
|
||||||
|
// and also via a dedicated localStorage key as a reliable fallback.
|
||||||
|
// Some browsers null out window.opener after cross-origin navigation.
|
||||||
|
try {
|
||||||
|
localStorage.setItem('mixchat_youtube_auth', JSON.stringify({ session: session, ts: Date.now() }));
|
||||||
|
} catch (e) { /* storage unavailable */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (window.opener && window.opener.postMessage) {
|
||||||
|
window.opener.postMessage({ type: 'mixchat_session', session: session }, window.location.origin);
|
||||||
|
}
|
||||||
|
} catch (e) { /* cross-origin opener */ }
|
||||||
|
|
||||||
|
// Close the popup if we were opened as one; otherwise do a full redirect.
|
||||||
|
if (window.opener) {
|
||||||
|
try { window.close(); } catch (e) {}
|
||||||
|
// fallback redirect in case close() is blocked
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
<\/script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("YouTube OAuth callback error:", error);
|
||||||
|
// Return a generic error page — never reflect raw error messages to the client.
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Auth Error</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Authentication Failed</h1>
|
||||||
|
<p>An error occurred during authentication. Please try again.</p>
|
||||||
|
<a href="/">Back to home</a>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{ status: 500, headers: { "Content-Type": "text/html" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
156
src/pages/index.astro
Normal file
156
src/pages/index.astro
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
import AppContainer from "../components/AppContainer";
|
||||||
|
import { DISPLAY_VERSION } from "../lib/version";
|
||||||
|
import "../styles/global.css";
|
||||||
|
import "../styles/AppContainer.css";
|
||||||
|
import "../styles/ChatDisplay.css";
|
||||||
|
import "../styles/LoginPanel.css";
|
||||||
|
import "../styles/MessageInput.css";
|
||||||
|
import "../styles/Notification.css";
|
||||||
|
import "../styles/LoadingOverlay.css";
|
||||||
|
import "../styles/EmoteMenu.css";
|
||||||
|
|
||||||
|
const title = "Mixchat - Advanced Twitch Chat Client";
|
||||||
|
const description =
|
||||||
|
"Enhanced Twitch chat client with support for emotes from 7TV, BTTV, and FFZ. Stream replies, badges, and third-party integrations.";
|
||||||
|
|
||||||
|
const mainUrl = "https://mixchat.grepfs.xyz/";
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<meta name="theme-color" content="#0a0a0a" />
|
||||||
|
<meta name="mobile-web-app-capable" content="true" />
|
||||||
|
<title>{title}</title>
|
||||||
|
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content={mainUrl} />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:site_name" content="Mixchat" />
|
||||||
|
<meta property="og:image" content={mainUrl + "favicon.png"} />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={mainUrl + "favicon.png"} />
|
||||||
|
|
||||||
|
<meta
|
||||||
|
name="keywords"
|
||||||
|
content="twitch chat, twitch emotes, 7tv, bttv, ffz, streaming client, chat client, youtube chat, youtube emotes, youtube chat client, mixchat"
|
||||||
|
/>
|
||||||
|
<meta name="author" content="Mixchat" />
|
||||||
|
<link rel="canonical" href={mainUrl} />
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://www.twitch.tv" />
|
||||||
|
<link rel="preconnect" href="https://www.youtube.com" />
|
||||||
|
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
set:html={JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebApplication",
|
||||||
|
name: "Mixchat",
|
||||||
|
description: description,
|
||||||
|
applicationCategory: "Utility",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<style>
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
padding-bottom: 50px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
border-top: 1px solid var(--color-border-medium);
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
z-index: 100;
|
||||||
|
height: 50px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer img {
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#app {
|
||||||
|
padding: 0;
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
height: 60px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer img {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</html>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<AppContainer client:load />
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<a href="/privacy">Privacy</a>
|
||||||
|
<a href="/terms">Terms</a>
|
||||||
|
|
||||||
|
<span
|
||||||
|
>© 2026 Mixchat by <a
|
||||||
|
href="https://github.com/grepfs17/Mixchat"
|
||||||
|
target="_blank">grepfs17</a
|
||||||
|
> — Licensed under <a
|
||||||
|
href="https://opensource.org/licenses/MIT"
|
||||||
|
target="_blank">MIT</a
|
||||||
|
> — {DISPLAY_VERSION}</span
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Mixchat Logo"
|
||||||
|
style="width: auto; height: 30px;"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
164
src/pages/privacy.astro
Normal file
164
src/pages/privacy.astro
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
---
|
||||||
|
import "../styles/global.css";
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Mixchat Privacy Policy" />
|
||||||
|
<title>Privacy Policy - Mixchat</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||||
|
Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="text-align: center; margin-bottom: 30px;">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Mixchat Logo"
|
||||||
|
style="width: auto; height: 80px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p class="meta">Last updated: March 29, 2026</p>
|
||||||
|
|
||||||
|
<h2>1. Overview</h2>
|
||||||
|
<p>
|
||||||
|
Mixchat is designed to function without a server-side database. No
|
||||||
|
messages, emails, or account credentials are stored on any server. Data
|
||||||
|
processing occurs locally in the visitor's browser. Once the user logs out
|
||||||
|
or clears their cache, session data is removed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Data Collection</h2>
|
||||||
|
|
||||||
|
<h3>2.1 Logging In (Twitch & YouTube)</h3>
|
||||||
|
<p>
|
||||||
|
When connecting a Twitch or YouTube account, the browser gets "keys"
|
||||||
|
(OAuth tokens) from those services.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Secure Storage:</strong> Key tokens are stored as <code
|
||||||
|
>httpOnly</code
|
||||||
|
> cookies. This helps block outside scripts from seeing them.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>UI Session:</strong> A little bit of info (like your username and
|
||||||
|
profile picture) is saved in <code>localStorage</code> so the app remembers
|
||||||
|
who you are on refresh.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>2.2 Your Messages</h3>
|
||||||
|
<p>
|
||||||
|
Mixchat is a real-time client. Chat messages flow from Twitch or YouTube
|
||||||
|
directly to the screen. They are kept in the browser's memory during a
|
||||||
|
session and are not archived.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>2.3 YouTube Integration</h3>
|
||||||
|
<p>
|
||||||
|
By linking a YouTube channel, Mixchat uses the official YouTube Data API.
|
||||||
|
It only requests public info to show live status and stream metadata
|
||||||
|
properly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Third-Party Services</h2>
|
||||||
|
<p>
|
||||||
|
To make everything work smoothly, Mixchat talks to a few core services:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Twitch & YouTube:</strong> For the chat and video feeds.</li>
|
||||||
|
<li>
|
||||||
|
<strong>Emote Providers (7TV, BTTV, FFZ):</strong> To render those custom
|
||||||
|
emotes everyone loves. These services might see which channel you're watching
|
||||||
|
so they can serve the right emote pack.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>4. Stateless and Database-Free</h2>
|
||||||
|
<p>
|
||||||
|
Mixchat's server/API is "stateless." This means it doesn't remember
|
||||||
|
sessions from one second to the next without the browser sending its
|
||||||
|
security key. There's no backend database where history or personal info
|
||||||
|
could be stored.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Data Retention and Deletion</h2>
|
||||||
|
<p>
|
||||||
|
Since no data is stored, "deleting an account" is straightforward.
|
||||||
|
Clicking
|
||||||
|
<strong>Logout</strong> wipes the cookies and session info from the browser.
|
||||||
|
Closing the tab clears the memory usage. For total assurance, clear the site
|
||||||
|
data in the browser settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Security</h2>
|
||||||
|
<p>
|
||||||
|
Login tokens are managed using secure HTTP-only cookies. It is recommended
|
||||||
|
to use strong passwords and two-factor authentication (2FA) on Twitch and
|
||||||
|
YouTube accounts.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Changes</h2>
|
||||||
|
<p>
|
||||||
|
Mixchat changes fast. The date at the top will be updated if there are
|
||||||
|
ever changes to how info is handled (like adding a new feature that needs
|
||||||
|
a different API).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Contact</h2>
|
||||||
|
<p>
|
||||||
|
If you have questions about any of this, open an issue on the
|
||||||
|
<a href="https://github.com/grepfs17/Mixchat" target="_blank"
|
||||||
|
>GitHub repository</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr style="margin-top: 50px; border: none; border-top: 1px solid #333;" />
|
||||||
|
<p
|
||||||
|
style="text-align: center; color: #666; font-size: 12px; margin-top: 20px;"
|
||||||
|
>
|
||||||
|
<a href="/">Back to Mixchat</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
125
src/pages/terms.astro
Normal file
125
src/pages/terms.astro
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
import "../styles/global.css";
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Mixchat Terms of Service" />
|
||||||
|
<title>Terms of Service - Mixchat</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||||
|
Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="text-align: center; margin-bottom: 30px;">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Mixchat Logo"
|
||||||
|
style="width: auto; height: 80px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1>Terms of Service</h1>
|
||||||
|
<p class="meta">Last updated: March 29, 2026</p>
|
||||||
|
|
||||||
|
<h2>1. Disclaimer</h2>
|
||||||
|
<p>Mixchat is an unofficial app with no connection to Twitch or YouTube.</p>
|
||||||
|
<p>
|
||||||
|
The app is provided "as is." If it breaks, or if platforms change their
|
||||||
|
APIs, Mixchat is not liable for service interruptions or account impacts.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. User Guidelines</h2>
|
||||||
|
<p>By using Mixchat, you're promising to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Follow the official <a
|
||||||
|
href="https://www.twitch.tv/legal/terms-of-service"
|
||||||
|
target="_blank">Twitch Terms</a
|
||||||
|
> and <a
|
||||||
|
href="https://www.twitch.tv/legal/community-guidelines"
|
||||||
|
target="_blank">Community Guidelines</a
|
||||||
|
>.
|
||||||
|
</li>
|
||||||
|
<li>Don't use the app to spam, harass, or crawl data unfairly.</li>
|
||||||
|
<li>Don't try to break the app or its API endpoints.</li>
|
||||||
|
<li>
|
||||||
|
If you link your YouTube, follow <a
|
||||||
|
href="https://www.youtube.com/t/terms"
|
||||||
|
target="_blank">Google's rules</a
|
||||||
|
> too.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Limited Liability</h2>
|
||||||
|
<p>
|
||||||
|
If data, followers, or other account assets are lost due to bugs or
|
||||||
|
downtime, Mixchat is not responsible. The project is maintained by the
|
||||||
|
community on GitHub.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Open Source & License</h2>
|
||||||
|
<p>
|
||||||
|
Mixchat is open source and licensed under the MIT License. Feel free to
|
||||||
|
peek at the code or contribute. Just respect the trademarks of Twitch,
|
||||||
|
Google, 7TV, etc.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Termination</h2>
|
||||||
|
<p>
|
||||||
|
Mixchat reserves the right to block access or change the application at
|
||||||
|
any time if usage violates these terms.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Contact</h2>
|
||||||
|
<p>
|
||||||
|
Questions? Reach out on
|
||||||
|
<a href="https://github.com/grepfs17/Mixchat" target="_blank">GitHub</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr style="margin-top: 50px; border: none; border-top: 1px solid #333;" />
|
||||||
|
<p
|
||||||
|
style="text-align: center; color: #666; font-size: 12px; margin-top: 20px;"
|
||||||
|
>
|
||||||
|
<a href="/">Back to Mixchat</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
66
src/styles/AppContainer.css
Normal file
66
src/styles/AppContainer.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
@import './vars.css';
|
||||||
|
|
||||||
|
/* AppContainer responsive layout */
|
||||||
|
|
||||||
|
@media (max-width: 1365px) {
|
||||||
|
/* Main grid layout */
|
||||||
|
[style*="gridTemplateColumns"][style*="280px"] {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar becomes floating/overlay */
|
||||||
|
[data-sidebar-section] {
|
||||||
|
display: flex !important;
|
||||||
|
position: fixed !important;
|
||||||
|
left: 0 !important;
|
||||||
|
top: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: 280px !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
box-shadow: var(--shadow-sm-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show close button on mobile */
|
||||||
|
.sidebar-close-btn-container {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video on top */
|
||||||
|
.app-grid {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
grid-template-rows: 40vh 1fr !important;
|
||||||
|
gap: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When stream embed is hidden, adjust grid to full height for chat */
|
||||||
|
.app-grid:not(:has([data-stream-embed])) {
|
||||||
|
grid-template-rows: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-grid > [data-stream-embed] {
|
||||||
|
grid-column: 1 !important;
|
||||||
|
grid-row: 1 !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide resizer */
|
||||||
|
.app-grid > div:nth-child(2) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat at bottom */
|
||||||
|
.app-grid > [data-chat-section] {
|
||||||
|
grid-column: 1 !important;
|
||||||
|
grid-row: 2 !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
overflow: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-resizer] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
588
src/styles/ChatDisplay.css
Normal file
588
src/styles/ChatDisplay.css
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
@import './vars.css';
|
||||||
|
|
||||||
|
.chat-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-white);
|
||||||
|
border-bottom: 1px solid var(--color-border-dark);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-button {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-button:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
border-color: var(--color-border-lighter);
|
||||||
|
color: var(--color-text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-content h2 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #4ade80;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--chat-font-size, 0.9rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styling */
|
||||||
|
.messages-container::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border-medium);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-border-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-messages {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-left: 3px solid var(--color-border-medium);
|
||||||
|
transition: background-color 0.2s ease, border-left-color 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:hover {
|
||||||
|
background: #252525;
|
||||||
|
border-left-color: #444444;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-single-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: var(--chat-font-size, 0.85rem);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-separator {
|
||||||
|
color: #94a3b8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
color: #cbd5e1;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: var(--chat-font-size, 0.9rem);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #444444;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #555555;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.go-to-bottom-button {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 16px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border: 1px solid var(--color-accent-hover);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: var(--shadow-accent-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.go-to-bottom-button:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
box-shadow: var(--shadow-accent-md);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.go-to-bottom-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-emote {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-emote-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
background: rgba(0, 0, 0, 0.95);
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 8px;
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: none;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
animation: tooltipFade 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-emote-tooltip img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-emote-tooltip-name {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tooltipFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle-button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle-button:hover {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-stream-button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-stream-button:hover {
|
||||||
|
background-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background-color: #dc2626;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-offline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background-color: #6b7280;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-link {
|
||||||
|
color: #e2e8f0;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-link:hover {
|
||||||
|
color: #9146ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-count-display {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uptime-display {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.preloaded {
|
||||||
|
opacity: 0.55;
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.bot {
|
||||||
|
opacity: 0.6;
|
||||||
|
background-color: #0f0f0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.redemption,
|
||||||
|
.message.subscription,
|
||||||
|
.message.resub,
|
||||||
|
.message.subgift {
|
||||||
|
background-color: rgba(145, 71, 255, 0.4);
|
||||||
|
border-left: 3px solid #9147ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.cheer {
|
||||||
|
background-color: rgba(255, 183, 0, 0.4);
|
||||||
|
border-left: 3px solid #ffb700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.mention {
|
||||||
|
background-color: rgba(99, 102, 241, 0.2);
|
||||||
|
border-left: 3px solid #6366f1;
|
||||||
|
padding-left: 8px;
|
||||||
|
margin-left: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-info {
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-target-content {
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 450px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-target-content .chat-emote {
|
||||||
|
height: 1.25em !important;
|
||||||
|
margin: 0 1px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author.self {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-link {
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design for lower resolutions */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-button {
|
||||||
|
font-size: 0;
|
||||||
|
gap: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-button svg {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-single-line {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.message-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-top: 4px;
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.message-single-line {
|
||||||
|
gap: 1px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 3px 4px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 1px;
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.message-separator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions {
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 3px;
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-left: auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
311
src/styles/EmoteMenu.css
Normal file
311
src/styles/EmoteMenu.css
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
@import './vars.css';
|
||||||
|
|
||||||
|
.emote-menu-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
animation: fadeIn 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-menu-modal {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: var(--shadow-accent-lg);
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-menu-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid var(--color-border-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-menu-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-menu-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-menu-close:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-menu-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--color-border-medium);
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-search-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: var(--shadow-accent-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-search-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-menu-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 2px solid var(--color-border-medium);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
height: 50px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-tab {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-tab:hover:not(.active) {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-tab.active {
|
||||||
|
border-bottom-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-menu-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-source-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-source-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-source-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid var(--color-border-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-source-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-source-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-source-count {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-disable-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-disable-btn:hover {
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-item:hover {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-accent-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-image {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-name {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-no-results {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-no-results-section {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.emote-menu-content::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-menu-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-menu-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border-medium);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-menu-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.emote-menu-modal {
|
||||||
|
width: 95%;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-image {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/styles/LiveNotification.css
Normal file
146
src/styles/LiveNotification.css
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
.live-notification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 2px solid #6366f1;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 320px;
|
||||||
|
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(400px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-notification-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-notification-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-badge {
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-name {
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-notification-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-notification-close:hover {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-avatar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-title {
|
||||||
|
color: #b0b0b0;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-count {
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-now-button {
|
||||||
|
width: 100%;
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-now-button:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-now-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.live-notification {
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
left: 10px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/styles/LoadingOverlay.css
Normal file
39
src/styles/LoadingOverlay.css
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
@import './vars.css';
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(10, 10, 10, 0.85);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 4px solid var(--color-border-medium);
|
||||||
|
border-top-color: var(--color-accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
387
src/styles/LoginPanel.css
Normal file
387
src/styles/LoginPanel.css
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
@import './vars.css';
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
box-shadow: none;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
color: #6366f1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
color: #4f46e5;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.youtube-icon-btn {
|
||||||
|
color: #FF0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.youtube-icon-btn:hover {
|
||||||
|
color: #cc0000;
|
||||||
|
border-color: #FF0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.small {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-login {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--color-border-medium);
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-login.twitch .platform-name {
|
||||||
|
color: var(--color-twitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-login.youtube .platform-name {
|
||||||
|
color: var(--color-youtube);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile-link {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile-link:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-image {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid var(--color-border-medium);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-image:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: block;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: calc(100vh - 475px);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item.selected {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background-color: rgba(158, 64, 115, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item-image {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #666666;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-button.active {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-join-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn,
|
||||||
|
.logout-btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, opacity 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn.twitch-btn {
|
||||||
|
background-color: #9146ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn.youtube-btn {
|
||||||
|
background-color: #ff0000;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
background-color: #333333;
|
||||||
|
color: #e2e8f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background-color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn.small {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channel list scrollbar styling */
|
||||||
|
.channel-list::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list::-webkit-scrollbar-track {
|
||||||
|
background: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #333333;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555555;
|
||||||
|
}
|
||||||
289
src/styles/MessageInput.css
Normal file
289
src/styles/MessageInput.css
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
@import './vars.css';
|
||||||
|
|
||||||
|
.message-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border-top: 1px solid var(--color-border-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-indicator {
|
||||||
|
order: -1;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-indicator > div:first-child {
|
||||||
|
order: 1;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-checkbox input {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-checkbox input:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-label {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 8px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-label.twitch {
|
||||||
|
color: var(--color-twitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-label.twitch:not(.disabled):hover {
|
||||||
|
background-color: rgba(145, 70, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-label.youtube {
|
||||||
|
color: var(--color-youtube);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-label.youtube:not(.disabled):hover {
|
||||||
|
background-color: rgba(255, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-label.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
resize: none;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-border-lighter);
|
||||||
|
box-shadow: 0 0 0 3px rgba(85, 85, 85, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea.twitch {
|
||||||
|
border-color: #9146ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea.twitch:focus {
|
||||||
|
border-color: #a970ff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea.youtube {
|
||||||
|
border-color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea.youtube:focus {
|
||||||
|
border-color: #ff3333;
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea:disabled {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #333333;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 36px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(85, 85, 85, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-picker-button {
|
||||||
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 100%;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-picker-button:hover:not(:disabled) {
|
||||||
|
background: #262626;
|
||||||
|
border-color: #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-picker-button:active:not(:disabled) {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-picker-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: #3d2817;
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
color: #fde047;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
/* Responsive design for lower resolutions */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.message-input {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-selector {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-checkbox input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 3px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
height: 40px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.message-input {
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-selector {
|
||||||
|
gap: 6px;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-checkbox {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-checkbox input {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
height: 36px;
|
||||||
|
gap: 4px;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea {
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
height: 100%;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-picker-button {
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 60px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/styles/Notification.css
Normal file
55
src/styles/Notification.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
@import './vars.css';
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
animation: slideIn 0.3s ease-out, slideOut 0.3s ease-out 2.7s forwards;
|
||||||
|
max-width: 300px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success {
|
||||||
|
background-color: var(--color-notification-success);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid var(--color-notification-success-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
background-color: var(--color-notification-error);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid var(--color-notification-error-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
background-color: var(--color-notification-info);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid var(--color-notification-info-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(400px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOut {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(400px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/styles/UnifiedChat.css
Normal file
99
src/styles/UnifiedChat.css
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
@import './vars.css';
|
||||||
|
|
||||||
|
.unified-chat-wrapper {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unified-messages-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag.twitch {
|
||||||
|
color: #9147ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag.youtube {
|
||||||
|
color: #FF0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reusing some classes from ChatDisplay via global scope or if added here */
|
||||||
|
.twitch-event-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author.twitch-author:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Ensure UnifiedChat specific overrides if needed */
|
||||||
|
|
||||||
|
/* Super Chat Styles - Reused from YouTubeChat.css */
|
||||||
|
.yt-superchat {
|
||||||
|
margin: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-header .avatar {
|
||||||
|
width: 40px !important;
|
||||||
|
height: 40px !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-author {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-amount {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-body {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-sticker {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin-top: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
346
src/styles/WatchlistManager.css
Normal file
346
src/styles/WatchlistManager.css
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
.watchlist-manager-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-manager {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: var(--shadow-accent-lg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-manager h2 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-add-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-search {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: text;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-search:hover,
|
||||||
|
.channel-search:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 72px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-search:focus ~ .channel-list-dropdown {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list-dropdown:hover {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-search:focus ~ .channel-list-dropdown:hover {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-channels-message {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--color-border-dark);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list-item:hover {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-thumb {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-display-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-username {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-channel-display {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:hover:not(:disabled) {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-items {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-items::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-items::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-items::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border-medium);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-items::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-item:hover {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-username {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button:hover {
|
||||||
|
color: var(--color-accent-hover);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-footer {
|
||||||
|
border-top: 1px solid var(--color-border-medium);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.watchlist-manager {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-manager h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
233
src/styles/YouTubeChat.css
Normal file
233
src/styles/YouTubeChat.css
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
.yt-chat-outer {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-chat-container {
|
||||||
|
font-size: var(--chat-font-size, 13px);
|
||||||
|
color: var(--color-text-white);
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styling - matching Twitch chat */
|
||||||
|
.yt-chat-container::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-chat-container::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-chat-container::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border-medium);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-chat-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-border-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-chat-status {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-chat-error {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-chat-error-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-chat-placeholder {
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-message-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 4px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-badge {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-emoji {
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 0 1px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-emoji.custom {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-emoji.standard {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-message-input-wrapper {
|
||||||
|
padding: 8px 10px;
|
||||||
|
gap: 8px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-input-group {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-autocomplete-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 -4px 20px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-autocomplete-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-autocomplete-item.selected {
|
||||||
|
background: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-autocomplete-item img {
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-autocomplete-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-autocomplete-name {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emote-autocomplete-type {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-status-refreshing {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: #0f0f0f;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.yt-status-login-required {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: #0f0f0f;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Super Chat Styles */
|
||||||
|
.yt-superchat {
|
||||||
|
margin: 4px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-header .avatar {
|
||||||
|
width: 40px !important;
|
||||||
|
height: 40px !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-author {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-amount {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-body {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-superchat-sticker {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin-top: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
363
src/styles/YouTubeLinker.css
Normal file
363
src/styles/YouTubeLinker.css
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
/* YouTubeLinker modal – mirrors WatchlistManager styles */
|
||||||
|
|
||||||
|
.ytlinker-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-manager {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px;
|
||||||
|
max-width: 540px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: var(--shadow-accent-lg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-manager h2 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-white);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-subtitle {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add section */
|
||||||
|
.ytlinker-add-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-inputs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-channel-select,
|
||||||
|
.ytlinker-url-input {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: text;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-search:hover,
|
||||||
|
.ytlinker-search:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown for channel list */
|
||||||
|
.ytlinker-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-search:focus ~ .ytlinker-dropdown {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-dropdown:hover {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-search:focus ~ .ytlinker-dropdown:hover {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-no-channels {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-channel-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--color-border-dark);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-channel-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-channel-item:hover {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.ytlinker-channel-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-channel-display-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-channel-username {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-selected-display {
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-add-button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #FF0000;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-add-button:hover:not(:disabled) {
|
||||||
|
background: #cc0000;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-add-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Linked items list */
|
||||||
|
.ytlinker-items {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-items::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-items::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-items::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border-medium);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-items::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-item:hover {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-item-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-item-details {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-item-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-item-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-item-url:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-remove-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #FF0000;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-remove-button:hover {
|
||||||
|
color: #cc0000;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.ytlinker-footer {
|
||||||
|
border-top: 1px solid var(--color-border-medium);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-info-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-close-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border: 1px solid var(--color-border-medium);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-close-button:hover {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.ytlinker-manager {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ytlinker-manager h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/styles/global.css
Normal file
36
src/styles/global.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
@import './vars.css';
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive layout - stack vertically on smaller screens */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
[data-app-grid] {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-stream-embed] {
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/styles/vars.css
Normal file
59
src/styles/vars.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/* Normalized Color Palette */
|
||||||
|
:root {
|
||||||
|
/* Primary Background Colors */
|
||||||
|
--color-bg-primary: #0a0a0a;
|
||||||
|
--color-bg-secondary: #1a1a1a;
|
||||||
|
--color-bg-tertiary: #2a2a2a;
|
||||||
|
--color-bg-hover: #3a3a3a;
|
||||||
|
|
||||||
|
/* Border & Divider Colors */
|
||||||
|
--color-border-dark: #1a1a1a;
|
||||||
|
--color-border-medium: #333333;
|
||||||
|
--color-border-light: #444444;
|
||||||
|
--color-border-lighter: #555555;
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
--color-text-primary: #e2e8f0;
|
||||||
|
--color-text-secondary: #94a3b8;
|
||||||
|
--color-text-muted: #64748b;
|
||||||
|
--color-text-white: #ffffff;
|
||||||
|
|
||||||
|
/* Platform Colors */
|
||||||
|
--color-twitch: #9146ff;
|
||||||
|
--color-youtube: #ff0000;
|
||||||
|
|
||||||
|
/* Accent Color */
|
||||||
|
--color-accent: #9e4073;
|
||||||
|
--color-accent-hover: #5e2645;
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
|
--color-status-success: #d1fae5;
|
||||||
|
--color-status-success-text: #065f46;
|
||||||
|
--color-status-error: #fee2e2;
|
||||||
|
--color-status-error-text: #7f1d1d;
|
||||||
|
|
||||||
|
/* Notification Colors */
|
||||||
|
--color-notification-success: #10b981;
|
||||||
|
--color-notification-success-border: #059669;
|
||||||
|
--color-notification-error: #ef4444;
|
||||||
|
--color-notification-error-border: #dc2626;
|
||||||
|
--color-notification-info: #3b82f6;
|
||||||
|
--color-notification-info-border: #2563eb;
|
||||||
|
|
||||||
|
/* Shadow */
|
||||||
|
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-sm-dark: 2px 0 10px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-accent-sm: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
--shadow-accent-md: 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||||
|
--shadow-accent-lg: 0 10px 40px rgba(99, 102, 241, 0.15);
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-sm: 3px;
|
||||||
|
--radius-md: 4px;
|
||||||
|
--radius-lg: 6px;
|
||||||
|
--radius-xl: 8px;
|
||||||
|
--radius-2xl: 12px;
|
||||||
|
--radius-full: 50%;
|
||||||
|
}
|
||||||
6
src/types/twitch-m3u8.d.ts
vendored
Normal file
6
src/types/twitch-m3u8.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
declare module "twitch-m3u8" {
|
||||||
|
export function getStream(channel: string, raw?: boolean): Promise<string>;
|
||||||
|
export default {
|
||||||
|
getStream,
|
||||||
|
};
|
||||||
|
}
|
||||||
44
test/components/watchlistManager.test.tsx
Normal file
44
test/components/watchlistManager.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import WatchlistManager from '../../src/components/WatchlistManager';
|
||||||
|
import { addWatchedStreamer } from '../../src/lib/streamMonitor';
|
||||||
|
|
||||||
|
describe('WatchlistManager component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds and removes a watched streamer using localStorage', async () => {
|
||||||
|
const availableChannels = [
|
||||||
|
{ id: '1', name: 'chan1', displayName: 'Channel One', title: '', profileImageUrl: '' },
|
||||||
|
{ id: '2', name: 'chan2', displayName: 'Channel Two', title: '', profileImageUrl: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
// Programmatically add one streamer to localStorage before rendering
|
||||||
|
addWatchedStreamer({ id: '1', name: 'chan1', displayName: 'Channel One' });
|
||||||
|
|
||||||
|
render(<WatchlistManager availableChannels={availableChannels} onClose={onClose} />);
|
||||||
|
|
||||||
|
// The watched streamer should appear
|
||||||
|
expect(screen.queryByText('No streamers being watched yet')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Channel One')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// localStorage should contain the saved streamer
|
||||||
|
const saved = JSON.parse(localStorage.getItem('mixchat_watched_streamers') || '[]');
|
||||||
|
expect(saved).toHaveLength(1);
|
||||||
|
expect(saved[0].id).toBe('1');
|
||||||
|
|
||||||
|
// Remove the streamer via the UI
|
||||||
|
const removeButton = screen.getByTitle('Remove from watchlist');
|
||||||
|
await userEvent.click(removeButton);
|
||||||
|
|
||||||
|
// Should be empty again (wait for DOM update)
|
||||||
|
await screen.findByText('No streamers being watched yet');
|
||||||
|
const savedAfter = JSON.parse(localStorage.getItem('mixchat_watched_streamers') || '[]');
|
||||||
|
expect(savedAfter).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
test/components/youtubeLinker.test.tsx
Normal file
43
test/components/youtubeLinker.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import YouTubeLinker from '../../src/components/YouTubeLinker';
|
||||||
|
import { addYoutubeLink } from '../../src/lib/youtubeLinks';
|
||||||
|
|
||||||
|
describe('YouTubeLinker component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays an existing link and allows removal', async () => {
|
||||||
|
const availableChannels = [
|
||||||
|
{ id: 'a', name: 'chanA', displayName: 'Channel A', title: 'Channel A title', profileImageUrl: '' },
|
||||||
|
{ id: 'b', name: 'chanB', displayName: 'Channel B', title: 'Channel B title', profileImageUrl: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Programmatically add a link
|
||||||
|
addYoutubeLink({
|
||||||
|
twitchChannelId: 'a',
|
||||||
|
twitchDisplayName: 'Channel A',
|
||||||
|
youtubeUrl: 'https://www.youtube.com/@channelA',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
render(<YouTubeLinker availableChannels={availableChannels} onClose={onClose} />);
|
||||||
|
|
||||||
|
// The linked item should appear
|
||||||
|
expect(screen.getByText('Channel A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('https://www.youtube.com/@channelA')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Remove via UI
|
||||||
|
const removeButton = screen.getByTitle('Remove YouTube link');
|
||||||
|
await userEvent.click(removeButton);
|
||||||
|
|
||||||
|
// Now the empty message should be shown
|
||||||
|
await screen.findByText('No YouTube channels linked yet');
|
||||||
|
const saved = JSON.parse(localStorage.getItem('mixchat_youtube_links') || '[]');
|
||||||
|
expect(saved).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
122
test/lib/twitch.followed.test.ts
Normal file
122
test/lib/twitch.followed.test.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { getFollowedChannels } from "../../src/lib/twitch";
|
||||||
|
|
||||||
|
describe("getFollowedChannels pagination + enrichment", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only online channels with profile images and viewer counts", async () => {
|
||||||
|
// We'll simulate multiple fetch responses depending on the URL
|
||||||
|
const fetchMock: any = vi.fn().mockImplementation(async (input: any) => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string" ? input : input.url?.toString() || "";
|
||||||
|
|
||||||
|
if (url.includes("/channels/followed")) {
|
||||||
|
// Serve two pages: first with cursor, second without
|
||||||
|
if (!fetchMock.page1Served) {
|
||||||
|
fetchMock.page1Served = true;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
broadcaster_id: "10",
|
||||||
|
broadcaster_login: "ch10",
|
||||||
|
broadcaster_name: "Ch10",
|
||||||
|
game_name: "Game1",
|
||||||
|
thumbnail_url: "thumb1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
broadcaster_id: "20",
|
||||||
|
broadcaster_login: "ch20",
|
||||||
|
broadcaster_name: "Ch20",
|
||||||
|
game_name: "Game2",
|
||||||
|
thumbnail_url: "thumb2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: "CURSOR1" },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// second page
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
broadcaster_id: "30",
|
||||||
|
broadcaster_login: "ch30",
|
||||||
|
broadcaster_name: "Ch30",
|
||||||
|
game_name: "Game3",
|
||||||
|
thumbnail_url: "thumb3",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/users?")) {
|
||||||
|
// User profile enrichment
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{ id: "10", profile_image_url: "p10" },
|
||||||
|
{ id: "20", profile_image_url: "p20" },
|
||||||
|
{ id: "30", profile_image_url: "p30" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/streams?")) {
|
||||||
|
// Streams: only user 20 is live
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
user_id: "20",
|
||||||
|
viewer_count: 50,
|
||||||
|
title: "Live Now",
|
||||||
|
started_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// default
|
||||||
|
return { ok: false, status: 404 };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
const result = await getFollowedChannels("token", "client", "me");
|
||||||
|
|
||||||
|
// Only channel with id '20' should be online
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
const ch = result[0];
|
||||||
|
expect(ch.id).toBe("20");
|
||||||
|
expect(ch.profileImageUrl).toBe("p20");
|
||||||
|
expect(ch.viewerCount).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when channels endpoint returns 401 Unauthorized", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ ok: false, status: 401 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(getFollowedChannels("token", "client", "me")).rejects.toThrow(
|
||||||
|
"Twitch API error: 401 - Unauthorized. Token may be expired or invalid.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
test/lib/twitch.test.ts
Normal file
72
test/lib/twitch.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { getStreamStatuses, getRecentMessages } from "../../src/lib/twitch";
|
||||||
|
|
||||||
|
describe("twitch lib mocked HTTP tests", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getStreamStatuses returns correct live/offline mapping", async () => {
|
||||||
|
// Stub fetch to return one live stream for user '1'
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
user_id: "1",
|
||||||
|
title: "Live Stream",
|
||||||
|
viewer_count: 123,
|
||||||
|
started_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getStreamStatuses("token", "client", ["1", "2"]);
|
||||||
|
|
||||||
|
expect(result["1"]).toBeDefined();
|
||||||
|
expect(result["1"].isLive).toBe(true);
|
||||||
|
expect(result["1"].title).toBe("Live Stream");
|
||||||
|
expect(result["1"].viewerCount).toBe(123);
|
||||||
|
|
||||||
|
expect(result["2"]).toBeDefined();
|
||||||
|
expect(result["2"].isLive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRecentMessages parses raw IRC messages", async () => {
|
||||||
|
const rawMsg =
|
||||||
|
"@badge-info=;badges=moderator/1;color=#1E90FF;display-name=Mod;emotes=;tmi-sent-ts=1620000000000;user-id=456 :mod!mod@mod.tmi.twitch.tv PRIVMSG #channel :Hello there";
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ messages: [rawMsg] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages = await getRecentMessages("channel");
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
const m = messages[0];
|
||||||
|
expect(m.author).toBe("mod");
|
||||||
|
expect(m.content).toBe("Hello there");
|
||||||
|
expect(m.userId).toBe("456");
|
||||||
|
expect(m.isPreloaded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRecentMessages returns empty array on non-ok response", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ ok: false, status: 500 }),
|
||||||
|
);
|
||||||
|
const messages = await getRecentMessages("channel");
|
||||||
|
expect(messages).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
test/lib/youtube.test.ts
Normal file
70
test/lib/youtube.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { getYoutubeUser } from "../../src/lib/youtube";
|
||||||
|
|
||||||
|
describe("getYoutubeUser", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns mapped user on success", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "chan1",
|
||||||
|
snippet: {
|
||||||
|
title: "Channel Title",
|
||||||
|
thumbnails: { default: { url: "http://example.com/avatar.png" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockResponse,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getYoutubeUser("token-abc");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
userId: "chan1",
|
||||||
|
displayName: "Channel Title",
|
||||||
|
profileImageUrl: "http://example.com/avatar.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when response not ok", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
statusText: "Unauthorized",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(getYoutubeUser("bad-token")).rejects.toThrow(
|
||||||
|
"Failed to get YouTube user: Unauthorized",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when no items returned", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ items: [] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(getYoutubeUser("token-xyz")).rejects.toThrow(
|
||||||
|
"No YouTube channel found",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
test/pages/auth/youtube/callback.test.ts
Normal file
55
test/pages/auth/youtube/callback.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock the youtube lib before importing the handler
|
||||||
|
vi.mock("../../../../src/lib/youtube", () => {
|
||||||
|
return {
|
||||||
|
getYoutubeAccessToken: vi.fn().mockResolvedValue({
|
||||||
|
access_token: "access123",
|
||||||
|
refresh_token: "refresh123",
|
||||||
|
expires_in: 3600,
|
||||||
|
token_type: "Bearer",
|
||||||
|
}),
|
||||||
|
getYoutubeUser: vi.fn().mockResolvedValue({
|
||||||
|
userId: "user123",
|
||||||
|
displayName: "Test User",
|
||||||
|
profileImageUrl: "http://example.com/img.png",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("YouTube callback route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets cookies and returns session HTML", async () => {
|
||||||
|
const { GET } = await import("../../../../src/pages/auth/youtube/callback");
|
||||||
|
|
||||||
|
const cookies = { set: vi.fn() } as any;
|
||||||
|
const url = new URL("http://localhost/?code=abc123&state=test_state");
|
||||||
|
|
||||||
|
const res: Response = await GET({ url, cookies } as any);
|
||||||
|
|
||||||
|
expect(res).toBeDefined();
|
||||||
|
expect((res as any).status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toBe("text/html");
|
||||||
|
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("mixchat_sessions");
|
||||||
|
expect(body).toContain("user123");
|
||||||
|
expect(body).toContain("access123");
|
||||||
|
|
||||||
|
expect(cookies.set).toHaveBeenCalled();
|
||||||
|
expect(cookies.set).toHaveBeenCalledWith(
|
||||||
|
"youtube_token",
|
||||||
|
"access123",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cookies.set).toHaveBeenCalledWith(
|
||||||
|
"youtube_refresh_token",
|
||||||
|
"refresh123",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
test/setup.ts
Normal file
19
test/setup.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { expect, type Assertion, type AsymmetricMatchersContaining } from 'vitest';
|
||||||
|
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||||
|
import { type TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
declare module 'vitest' {
|
||||||
|
interface Assertion<T = any> extends TestingLibraryMatchers<typeof expect.stringContaining, T> { }
|
||||||
|
interface AsymmetricMatchersContaining extends TestingLibraryMatchers<any, any> { }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect.extend(matchers);
|
||||||
|
|
||||||
|
|
||||||
|
// In Node 20 / Vitest environment, Response and Headers are already
|
||||||
|
// defined globally. Polyfilling them from node-fetch is no longer
|
||||||
|
// required and avoids deprecation warnings.
|
||||||
|
if (typeof globalThis.Response === 'undefined') {
|
||||||
|
console.warn('Polyfilling Response for test environment');
|
||||||
|
}
|
||||||
38
tsconfig.json
Normal file
38
tsconfig.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsxImportSource": "react",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"types": [
|
||||||
|
"node",
|
||||||
|
"@testing-library/jest-dom"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"test/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
10
vitest.config.ts
Normal file
10
vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: 'test/setup.ts',
|
||||||
|
include: ['test/**/*.test.{ts,tsx}'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user