mirror of
https://github.com/imputnet/cobalt.git
synced 2025-06-28 01:18:27 +00:00
文件传输11-修复加入会话不稳定的问题
This commit is contained in:
parent
481f8d0e9c
commit
7777566e7a
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import SettingsCategory from '$components/settings/SettingsCategory.svelte';
|
||||
import ActionButton from '$components/buttons/ActionButton.svelte';
|
||||
|
||||
@ -9,6 +9,72 @@
|
||||
export let receivedText: string;
|
||||
export let peerConnected: boolean;
|
||||
|
||||
let previousReceivedText = '';
|
||||
let showNewMessageNotification = false;
|
||||
let isNewMessage = false;
|
||||
let receivedTextElement: HTMLElement;
|
||||
|
||||
// 监听接收文本的变化
|
||||
$: if (receivedText !== previousReceivedText && receivedText && previousReceivedText !== '') {
|
||||
handleNewTextReceived();
|
||||
previousReceivedText = receivedText;
|
||||
}
|
||||
|
||||
// 初始化时记录当前文本
|
||||
onMount(() => {
|
||||
previousReceivedText = receivedText;
|
||||
});
|
||||
|
||||
function handleNewTextReceived() {
|
||||
// 显示新消息通知
|
||||
showNewMessageNotification = true;
|
||||
isNewMessage = true;
|
||||
|
||||
// 滚动到接收文本区域
|
||||
if (receivedTextElement) {
|
||||
receivedTextElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
}
|
||||
|
||||
// 3秒后隐藏通知
|
||||
setTimeout(() => {
|
||||
showNewMessageNotification = false;
|
||||
}, 3000);
|
||||
|
||||
// 5秒后移除新消息高亮
|
||||
setTimeout(() => {
|
||||
isNewMessage = false;
|
||||
}, 5000);
|
||||
|
||||
// 可选:播放提示音
|
||||
playNotificationSound();
|
||||
}
|
||||
function playNotificationSound() {
|
||||
try {
|
||||
// 创建一个短暂的提示音
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
|
||||
oscillator.frequency.setValueAtTime(600, audioContext.currentTime + 0.1);
|
||||
|
||||
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.2);
|
||||
} catch (error) {
|
||||
// 如果音频播放失败,忽略错误
|
||||
console.log('Audio notification not available');
|
||||
}
|
||||
}
|
||||
|
||||
function sendText(): void {
|
||||
if (textContent.trim()) {
|
||||
dispatch('sendText', { text: textContent });
|
||||
@ -16,37 +82,43 @@
|
||||
}
|
||||
|
||||
function clearReceivedText(): void {
|
||||
isNewMessage = false;
|
||||
showNewMessageNotification = false;
|
||||
dispatch('clearText');
|
||||
}
|
||||
|
||||
function copyReceivedText(): void {
|
||||
if (receivedText && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(receivedText);
|
||||
isNewMessage = false;
|
||||
}
|
||||
}
|
||||
|
||||
function dismissNotification() {
|
||||
showNewMessageNotification = false;
|
||||
isNewMessage = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 新消息通知 -->
|
||||
{#if showNewMessageNotification}
|
||||
<div class="new-message-notification" on:click={dismissNotification}>
|
||||
<div class="notification-content">
|
||||
<div class="notification-icon">📩</div>
|
||||
<div class="notification-text">
|
||||
<strong>收到新消息</strong>
|
||||
<span>点击此处查看</span>
|
||||
</div>
|
||||
<button class="notification-close" on:click|stopPropagation={dismissNotification}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SettingsCategory title="文本分享" sectionId="text-sharing">
|
||||
<div class="text-sharing-section">
|
||||
<div class="send-text">
|
||||
<h4>发送文本</h4>
|
||||
<textarea
|
||||
class="text-input"
|
||||
bind:value={textContent}
|
||||
placeholder="输入要发送的文本..."
|
||||
rows="4"
|
||||
disabled={!peerConnected}
|
||||
></textarea> <ActionButton
|
||||
id="send-text"
|
||||
disabled={!peerConnected || !textContent.trim()}
|
||||
click={sendText}
|
||||
>
|
||||
发送文本
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<div class="received-text">
|
||||
<h4>已接收文本</h4>
|
||||
<!-- 接收文本区域 - 移到上方 -->
|
||||
<div class="received-text" class:new-message={isNewMessage} bind:this={receivedTextElement}>
|
||||
<h4>已接收文本 {#if isNewMessage}<span class="new-badge">新</span>{/if}</h4>
|
||||
{#if receivedText}
|
||||
<div class="text-display">
|
||||
<div class="text-content">{receivedText}</div>
|
||||
@ -71,10 +143,128 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 发送文本区域 - 移到下方 -->
|
||||
<div class="send-text">
|
||||
<h4>发送文本</h4>
|
||||
<textarea
|
||||
class="text-input"
|
||||
bind:value={textContent}
|
||||
placeholder="输入要发送的文本..."
|
||||
rows="4"
|
||||
disabled={!peerConnected}
|
||||
></textarea>
|
||||
<ActionButton
|
||||
id="send-text"
|
||||
disabled={!peerConnected || !textContent.trim()}
|
||||
click={sendText}
|
||||
>
|
||||
发送文本
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCategory>
|
||||
|
||||
<style>
|
||||
/* 新消息通知样式 */
|
||||
.new-message-notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3);
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
cursor: pointer;
|
||||
max-width: 320px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
gap: 0.75rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-text strong {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.notification-text span {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 新消息徽章 */
|
||||
.new-badge {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
margin-left: 0.5rem;
|
||||
animation: pulse 2s infinite;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.text-sharing-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -94,12 +284,35 @@
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 新消息状态的特殊样式 */
|
||||
.received-text.new-message {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
|
||||
animation: glow 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
from {
|
||||
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 30px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.send-text:hover, .received-text:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.received-text.new-message:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 35px rgba(102, 126, 234, 0.3);
|
||||
border-color: rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.send-text h4, .received-text h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
@ -108,6 +321,8 @@
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
@ -124,7 +339,9 @@
|
||||
min-height: 120px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
} .text-input:focus {
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(102, 126, 234, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1), 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
@ -152,17 +369,17 @@
|
||||
}
|
||||
|
||||
.text-content {
|
||||
padding: 1.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
padding: 1.5rem;
|
||||
margin: 0;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
max-height: 250px;
|
||||
line-height: 1.7;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
min-height: 60px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.005) 100%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.text-content::-webkit-scrollbar {
|
||||
@ -170,86 +387,168 @@
|
||||
}
|
||||
|
||||
.text-content::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.text-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.text-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.text-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.copy-btn, .clear-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
color: #667eea;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: linear-gradient(135deg, rgba(244, 67, 54, 0.2) 0%, rgba(233, 30, 99, 0.2) 100%);
|
||||
background: linear-gradient(135deg, rgba(244, 67, 54, 0.1) 0%, rgba(229, 57, 53, 0.1) 100%);
|
||||
border-color: rgba(244, 67, 54, 0.3);
|
||||
color: #f44336;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.2);
|
||||
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.2);
|
||||
}
|
||||
|
||||
.copy-btn:active, .clear-btn:active {
|
||||
transform: translateY(0) scale(0.95);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--secondary);
|
||||
font-style: italic;
|
||||
padding: 3rem 2rem;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.005) 100%);
|
||||
transition: all 0.3s ease;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.1);
|
||||
font-style: italic;
|
||||
backdrop-filter: blur(4px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-state:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
|
||||
.empty-state::before {
|
||||
content: '📭';
|
||||
display: block;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent);
|
||||
animation: shimmer 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
left: -100%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.new-message-notification {
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
max-width: none;
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
.text-sharing-section {
|
||||
gap: 1.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.send-text, .received-text {
|
||||
padding: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
min-height: 100px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.text-actions {
|
||||
flex-direction: column;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.copy-btn, .clear-btn {
|
||||
width: 100%;
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.empty-state::before {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.notification-content {
|
||||
padding: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.notification-text strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notification-text span {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -41,7 +41,10 @@ export const clipboardState = writable({
|
||||
receivingFiles: false,
|
||||
transferProgress: 0,
|
||||
dataChannel: null as RTCDataChannel | null,
|
||||
peerConnection: null as RTCPeerConnection | null
|
||||
peerConnection: null as RTCPeerConnection | null,
|
||||
errorMessage: '' as string,
|
||||
showError: false as boolean,
|
||||
waitingForCreator: false as boolean
|
||||
});
|
||||
|
||||
export class ClipboardManager {
|
||||
@ -92,19 +95,31 @@ export class ClipboardManager {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('clipboard_session');
|
||||
}
|
||||
}
|
||||
|
||||
private startStatusCheck(): void {
|
||||
} private startStatusCheck(): void {
|
||||
// 在移动端使用更频繁的状态检查以确保UI及时更新
|
||||
const isMobile = typeof window !== 'undefined' &&
|
||||
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
|
||||
const checkInterval = isMobile ? 300 : 1000; // 移动端300ms,桌面端1秒
|
||||
|
||||
this.statusInterval = setInterval(() => {
|
||||
const wsConnected = this.ws?.readyState === WebSocket.OPEN;
|
||||
const peerConnected = this.dataChannel?.readyState === 'open';
|
||||
|
||||
clipboardState.update(state => ({
|
||||
...state,
|
||||
isConnected: wsConnected,
|
||||
peerConnected: peerConnected
|
||||
}));
|
||||
}, 1000);
|
||||
// 获取当前状态避免不必要的更新
|
||||
let currentState: any = {};
|
||||
const unsubscribe = clipboardState.subscribe(s => currentState = s);
|
||||
unsubscribe();
|
||||
|
||||
// 只在状态真正变化时更新
|
||||
if (currentState.isConnected !== wsConnected || currentState.peerConnected !== peerConnected) {
|
||||
console.log('📱 Status update:', { wsConnected, peerConnected, isMobile });
|
||||
clipboardState.update(state => ({
|
||||
...state,
|
||||
isConnected: wsConnected,
|
||||
peerConnected: peerConnected
|
||||
}));
|
||||
}
|
||||
}, checkInterval);
|
||||
}
|
||||
|
||||
// WebSocket management
|
||||
@ -353,9 +368,7 @@ export class ClipboardManager {
|
||||
const url = `${origin}/clipboard?session=${sessionId}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
} cleanup(): void {
|
||||
if (this.dataChannel) {
|
||||
this.dataChannel.close();
|
||||
this.dataChannel = null;
|
||||
@ -377,7 +390,10 @@ export class ClipboardManager {
|
||||
sessionId: '',
|
||||
isConnected: false,
|
||||
peerConnected: false,
|
||||
qrCodeUrl: ''
|
||||
qrCodeUrl: '',
|
||||
errorMessage: '',
|
||||
showError: false,
|
||||
waitingForCreator: false
|
||||
}));
|
||||
|
||||
this.sharedKey = null;
|
||||
@ -385,7 +401,15 @@ export class ClipboardManager {
|
||||
this.clearStoredSession();
|
||||
}
|
||||
|
||||
// WebSocket message handler
|
||||
// 清除错误消息
|
||||
clearError(): void {
|
||||
clipboardState.update(state => ({
|
||||
...state,
|
||||
errorMessage: '',
|
||||
showError: false,
|
||||
waitingForCreator: false
|
||||
}));
|
||||
}// WebSocket message handler
|
||||
private async handleWebSocketMessage(message: any): Promise<void> {
|
||||
console.log('Handling WebSocket message:', message.type);
|
||||
|
||||
@ -395,7 +419,9 @@ export class ClipboardManager {
|
||||
...state,
|
||||
sessionId: message.sessionId,
|
||||
isCreating: false,
|
||||
isCreator: true
|
||||
isCreator: true,
|
||||
errorMessage: '',
|
||||
showError: false
|
||||
}));
|
||||
await this.generateQRCode(message.sessionId);
|
||||
this.saveSession(message.sessionId, true);
|
||||
@ -405,7 +431,13 @@ export class ClipboardManager {
|
||||
console.log('Session joined successfully, setting up WebRTC...');
|
||||
await this.importRemotePublicKey(message.publicKey);
|
||||
await this.deriveSharedKey();
|
||||
clipboardState.update(state => ({ ...state, isJoining: false }));
|
||||
clipboardState.update(state => ({
|
||||
...state,
|
||||
isJoining: false,
|
||||
waitingForCreator: false,
|
||||
errorMessage: '',
|
||||
showError: false
|
||||
}));
|
||||
await this.setupWebRTC(false);
|
||||
break;
|
||||
|
||||
@ -426,6 +458,36 @@ export class ClipboardManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'waiting_for_creator':
|
||||
console.log('Waiting for creator to reconnect...');
|
||||
clipboardState.update(state => ({
|
||||
...state,
|
||||
isJoining: false,
|
||||
waitingForCreator: true,
|
||||
errorMessage: message.message || '等待创建者重新连接',
|
||||
showError: true
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'peer_disconnected':
|
||||
console.log('Peer disconnected');
|
||||
clipboardState.update(state => ({
|
||||
...state,
|
||||
peerConnected: false,
|
||||
errorMessage: '对端已断开连接',
|
||||
showError: true
|
||||
}));
|
||||
// 清理 WebRTC 连接但保持 WebSocket 连接
|
||||
if (this.peerConnection) {
|
||||
this.peerConnection.close();
|
||||
this.peerConnection = null;
|
||||
}
|
||||
if (this.dataChannel) {
|
||||
this.dataChannel.close();
|
||||
this.dataChannel = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'offer':
|
||||
await this.handleOffer(message.offer);
|
||||
break;
|
||||
@ -440,20 +502,48 @@ export class ClipboardManager {
|
||||
|
||||
case 'error':
|
||||
console.error('Server error:', message);
|
||||
let errorMessage = '连接错误';
|
||||
|
||||
// 处理特定的错误消息
|
||||
if (message.message) {
|
||||
switch (message.message) {
|
||||
case '会话不存在或已过期':
|
||||
errorMessage = '会话已过期或不存在,请创建新会话';
|
||||
this.clearStoredSession(); // 清理本地存储的过期会话
|
||||
break;
|
||||
case '会话已满':
|
||||
errorMessage = '会话已满,无法加入';
|
||||
break;
|
||||
case '未知消息类型':
|
||||
errorMessage = '通信协议错误';
|
||||
break;
|
||||
case '消息格式错误':
|
||||
errorMessage = '数据格式错误';
|
||||
break;
|
||||
default:
|
||||
errorMessage = message.message;
|
||||
}
|
||||
}
|
||||
|
||||
clipboardState.update(state => ({
|
||||
...state,
|
||||
isCreating: false,
|
||||
isJoining: false
|
||||
isJoining: false,
|
||||
waitingForCreator: false,
|
||||
errorMessage,
|
||||
showError: true
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// WebRTC setup and handlers
|
||||
} // WebRTC setup and handlers
|
||||
private async setupWebRTC(isInitiator: boolean): Promise<void> {
|
||||
try {
|
||||
this.peerConnection = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' } // 添加备用STUN服务器
|
||||
],
|
||||
iceCandidatePoolSize: 10 // 增加ICE候选池大小
|
||||
});
|
||||
|
||||
// Update state with peer connection
|
||||
@ -462,37 +552,84 @@ export class ClipboardManager {
|
||||
peerConnection: this.peerConnection
|
||||
}));
|
||||
|
||||
// 添加ICE连接状态监听
|
||||
this.peerConnection.oniceconnectionstatechange = () => {
|
||||
const iceState = this.peerConnection?.iceConnectionState;
|
||||
console.log('🧊 ICE connection state changed:', iceState);
|
||||
|
||||
if (iceState === 'failed') {
|
||||
console.warn('❌ ICE connection failed, attempting restart...');
|
||||
this.restartIce();
|
||||
}
|
||||
};
|
||||
|
||||
this.peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate && this.ws) {
|
||||
console.log('📡 Sending ICE candidate:', event.candidate.type);
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'ice_candidate',
|
||||
candidate: event.candidate
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
this.peerConnection.onconnectionstatechange = () => {
|
||||
}; this.peerConnection.onconnectionstatechange = () => {
|
||||
const state = this.peerConnection?.connectionState;
|
||||
console.log('🔗 Peer connection state changed:', state);
|
||||
|
||||
// 检测移动设备
|
||||
const isMobile = typeof window !== 'undefined' &&
|
||||
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
|
||||
|
||||
if (state === 'connected') {
|
||||
console.log('🎉 Peer connected!');
|
||||
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
||||
} else if (state === 'failed' || state === 'disconnected') {
|
||||
console.warn('❌ Peer connection failed/disconnected');
|
||||
|
||||
// 移动端额外确认连接状态
|
||||
if (isMobile) {
|
||||
setTimeout(() => {
|
||||
console.log('📱 Mobile peer connection confirmation');
|
||||
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
||||
}, 150);
|
||||
}
|
||||
} else if (state === 'failed') {
|
||||
console.warn('❌ Peer connection failed, retrying...');
|
||||
clipboardState.update(state => ({ ...state, peerConnected: false }));
|
||||
|
||||
// 移动端使用更短的重试间隔
|
||||
const retryDelay = isMobile ? 1000 : 2000;
|
||||
setTimeout(() => {
|
||||
if (this.peerConnection?.connectionState === 'failed') {
|
||||
console.log(`🔄 Attempting to restart peer connection (${isMobile ? 'mobile' : 'desktop'})...`);
|
||||
this.restartWebRTC();
|
||||
}
|
||||
}, retryDelay);
|
||||
} else if (state === 'disconnected') {
|
||||
console.warn('⚠️ Peer connection disconnected');
|
||||
clipboardState.update(state => ({ ...state, peerConnected: false }));
|
||||
|
||||
// 移动端快速恢复尝试
|
||||
if (isMobile) {
|
||||
setTimeout(() => {
|
||||
if (this.peerConnection?.connectionState === 'disconnected') {
|
||||
console.log('📱 Mobile reconnection attempt...');
|
||||
this.restartWebRTC();
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isInitiator) {
|
||||
};if (isInitiator) {
|
||||
this.dataChannel = this.peerConnection.createDataChannel('files', {
|
||||
ordered: true
|
||||
ordered: true,
|
||||
maxRetransmits: 3 // 增加重传次数
|
||||
});
|
||||
this.setupDataChannel();
|
||||
|
||||
// 为移动端增加延迟,确保ICE candidates收集完成
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const offer = await this.peerConnection.createOffer();
|
||||
await this.peerConnection.setLocalDescription(offer);
|
||||
|
||||
console.log('📤 Sending offer to peer...');
|
||||
if (this.ws) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'offer',
|
||||
@ -501,16 +638,59 @@ export class ClipboardManager {
|
||||
}
|
||||
} else {
|
||||
this.peerConnection.ondatachannel = (event) => {
|
||||
console.log('📥 Data channel received');
|
||||
this.dataChannel = event.channel;
|
||||
this.setupDataChannel();
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting up WebRTC:', error);
|
||||
// 添加错误恢复
|
||||
setTimeout(() => {
|
||||
console.log('🔄 Retrying WebRTC setup...');
|
||||
this.setupWebRTC(isInitiator);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
private setupDataChannel(): void {
|
||||
// 添加ICE重启方法
|
||||
private async restartIce(): Promise<void> {
|
||||
try {
|
||||
if (this.peerConnection && this.peerConnection.connectionState !== 'closed') {
|
||||
console.log('🔄 Restarting ICE...');
|
||||
await this.peerConnection.restartIce();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error restarting ICE:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加WebRTC重启方法
|
||||
private async restartWebRTC(): Promise<void> {
|
||||
try {
|
||||
console.log('🔄 Restarting WebRTC connection...');
|
||||
|
||||
// 清理现有连接
|
||||
if (this.dataChannel) {
|
||||
this.dataChannel.close();
|
||||
this.dataChannel = null;
|
||||
}
|
||||
if (this.peerConnection) {
|
||||
this.peerConnection.close();
|
||||
this.peerConnection = null;
|
||||
}
|
||||
|
||||
// 获取当前状态以确定是否为发起者
|
||||
let currentState: any = {};
|
||||
const unsubscribe = clipboardState.subscribe(s => currentState = s);
|
||||
unsubscribe();
|
||||
|
||||
// 重新建立连接
|
||||
await this.setupWebRTC(currentState.isCreator);
|
||||
} catch (error) {
|
||||
console.error('Error restarting WebRTC:', error);
|
||||
}
|
||||
} private setupDataChannel(): void {
|
||||
if (!this.dataChannel) return;
|
||||
|
||||
// Update state with data channel
|
||||
@ -521,7 +701,18 @@ export class ClipboardManager {
|
||||
|
||||
this.dataChannel.onopen = () => {
|
||||
console.log('🎉 Data channel opened!');
|
||||
// 立即更新状态,不等待状态检查间隔
|
||||
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
||||
|
||||
// 移动端额外的状态确认延迟,确保UI有足够时间响应
|
||||
const isMobile = typeof window !== 'undefined' &&
|
||||
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
|
||||
if (isMobile) {
|
||||
setTimeout(() => {
|
||||
console.log('📱 Mobile connection confirmation');
|
||||
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
this.dataChannel.onclose = () => {
|
||||
@ -531,6 +722,17 @@ export class ClipboardManager {
|
||||
|
||||
this.dataChannel.onerror = (error) => {
|
||||
console.error('Data channel error:', error);
|
||||
// 移动端错误恢复机制
|
||||
const isMobile = typeof window !== 'undefined' &&
|
||||
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
|
||||
if (isMobile) {
|
||||
console.log('📱 Mobile data channel error, attempting recovery...');
|
||||
setTimeout(() => {
|
||||
if (this.dataChannel?.readyState === 'open') {
|
||||
clipboardState.update(state => ({ ...state, peerConnected: true }));
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}; this.dataChannel.onmessage = async (event) => {
|
||||
console.log('📨 Data channel message received:', event.data);
|
||||
try {
|
||||
|
@ -48,6 +48,11 @@
|
||||
let dataChannel: RTCDataChannel | null = null;
|
||||
let peerConnection: RTCPeerConnection | null = null;
|
||||
|
||||
// Error handling state
|
||||
let errorMessage = '';
|
||||
let showError = false;
|
||||
let waitingForCreator = false;
|
||||
|
||||
// Clipboard manager instance
|
||||
let clipboardManager: ClipboardManager; // Subscribe to clipboard state
|
||||
$: if (clipboardManager) {
|
||||
@ -71,6 +76,9 @@
|
||||
transferProgress = state.transferProgress;
|
||||
dataChannel = state.dataChannel;
|
||||
peerConnection = state.peerConnection;
|
||||
errorMessage = state.errorMessage;
|
||||
showError = state.showError;
|
||||
waitingForCreator = state.waitingForCreator;
|
||||
});
|
||||
}
|
||||
|
||||
@ -143,10 +151,15 @@
|
||||
clipboardManager?.sendText(text);
|
||||
} else {
|
||||
console.log('No text to send');
|
||||
}
|
||||
} function handleClearText() {
|
||||
} } function handleClearText() {
|
||||
clipboardState.update(state => ({ ...state, receivedText: '' }));
|
||||
}// Lifecycle functions
|
||||
}
|
||||
|
||||
function handleClearError() {
|
||||
clipboardManager?.clearError();
|
||||
}
|
||||
|
||||
// Lifecycle functions
|
||||
onMount(async () => {
|
||||
clipboardManager = new ClipboardManager();
|
||||
|
||||
@ -171,27 +184,37 @@
|
||||
<meta property="description" content={$t("clipboard.description")} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="clipboard-container"> <div class="clipboard-header">
|
||||
<div class="clipboard-container">
|
||||
<div class="clipboard-header">
|
||||
<h1>{$t("clipboard.title")}</h1>
|
||||
<div class="description-container">
|
||||
<p class="description-main">{$t("clipboard.description")}</p>
|
||||
<p class="description-subtitle">{$t("clipboard.description_subtitle")}</p>
|
||||
</div> </div>
|
||||
|
||||
<!-- Error Message Display -->
|
||||
{#if showError && errorMessage}
|
||||
<div class="error-notification" class:waiting={waitingForCreator}>
|
||||
<div class="error-content">
|
||||
<div class="error-icon">
|
||||
{#if waitingForCreator}
|
||||
<div class="spinner"></div>
|
||||
{:else}
|
||||
⚠️
|
||||
{/if}
|
||||
</div> <div class="error-text">
|
||||
<strong>{waitingForCreator ? '等待中' : '提示'}</strong>
|
||||
<p>{errorMessage}</p>
|
||||
</div>
|
||||
<button class="error-close" on:click={handleClearError} aria-label="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- Session Management Component -->
|
||||
<SessionManager
|
||||
{sessionId}
|
||||
{isConnected}
|
||||
{isCreating}
|
||||
{isJoining}
|
||||
{isCreator}
|
||||
{peerConnected}
|
||||
{qrCodeUrl}
|
||||
on:createSession={handleCreateSession}
|
||||
on:joinSession={handleJoinSession}
|
||||
on:shareSession={handleShareSession}
|
||||
on:cleanup={handleCleanup}
|
||||
bind:joinCode
|
||||
/> {#if isConnected && peerConnected} <!-- Connection Status Indicator -->
|
||||
{/if}
|
||||
|
||||
{#if isConnected && peerConnected}
|
||||
<!-- Connection Status Indicator -->
|
||||
<div class="session-status">
|
||||
<div class="status-badge">
|
||||
<div class="status-dot connected"></div>
|
||||
@ -199,13 +222,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation Component -->
|
||||
<!-- Tab Navigation Component - Moved to top -->
|
||||
<TabNavigation
|
||||
{activeTab}
|
||||
on:tabChange={handleTabChange}
|
||||
/>
|
||||
|
||||
<!-- Content Area -->
|
||||
<!-- Content Area - Moved to top -->
|
||||
<div class="tab-content">
|
||||
<!-- File Transfer Component -->
|
||||
{#if activeTab === 'files'}
|
||||
@ -233,8 +256,41 @@
|
||||
on:sendText={handleSendText}
|
||||
on:clearText={handleClearText}
|
||||
bind:textContent
|
||||
/> {/if}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Session Management Section - Moved to bottom -->
|
||||
<div class="session-management-section">
|
||||
<div class="session-info">
|
||||
<h3>会话管理</h3>
|
||||
<p>会话ID: <code>{sessionId}</code></p>
|
||||
<div class="session-actions">
|
||||
<button class="btn-secondary" on:click={handleShareSession}>
|
||||
分享会话
|
||||
</button>
|
||||
<button class="btn-secondary danger" on:click={handleCleanup}>
|
||||
断开连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Session Management Component - Show when not connected -->
|
||||
<SessionManager
|
||||
{sessionId}
|
||||
{isConnected}
|
||||
{isCreating}
|
||||
{isJoining}
|
||||
{isCreator}
|
||||
{peerConnected}
|
||||
{qrCodeUrl}
|
||||
on:createSession={handleCreateSession}
|
||||
on:joinSession={handleJoinSession}
|
||||
on:shareSession={handleShareSession}
|
||||
on:cleanup={handleCleanup}
|
||||
bind:joinCode
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@ -302,9 +358,106 @@
|
||||
font-weight: 400 !important;
|
||||
opacity: 0.7 !important;
|
||||
margin: 0;
|
||||
max-width: 600px;
|
||||
line-height: 1.4;
|
||||
} /* Enhanced session status */
|
||||
max-width: 600px; line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Error notification styles */
|
||||
.error-notification {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(8px);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.error-notification.waiting {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1.2rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(34, 197, 94, 0.3);
|
||||
border-top: 2px solid #22c55e;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-text strong {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.error-notification.waiting .error-text strong {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.error-text p {
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.error-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced session status */
|
||||
.session-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -357,11 +510,86 @@
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: visible; /* 允许通知显示在容器外 */
|
||||
}
|
||||
|
||||
.tab-content:hover {
|
||||
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.12); }
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Session management section styling */
|
||||
.session-management-section {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 15px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.session-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-info h3 {
|
||||
color: var(--text);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.session-info p {
|
||||
color: var(--subtext);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.session-info code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary.danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-secondary.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
/* Enhanced card-like sections */
|
||||
:global(.card) {
|
||||
|
Loading…
Reference in New Issue
Block a user