260 lines
9.6 KiB
TypeScript
260 lines
9.6 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useRef } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useToast } from '@/hooks/useToast'
|
||
import ToastBubble from './ToastBubble'
|
||
|
||
interface Danmaku {
|
||
id: string
|
||
content: string
|
||
username: string
|
||
createdAt: string
|
||
}
|
||
|
||
export default function DanmakuWidget() {
|
||
const { data: session } = useSession()
|
||
const [isOpen, setIsOpen] = useState(false)
|
||
const [content, setContent] = useState('')
|
||
const [danmakus, setDanmakus] = useState<Danmaku[]>([])
|
||
const [sending, setSending] = useState(false)
|
||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
const { toast, showToast } = useToast()
|
||
|
||
// 滚动到底部
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||
}
|
||
|
||
// 获取弹幕
|
||
useEffect(() => {
|
||
if (!isOpen) return
|
||
|
||
const fetchDanmakus = async () => {
|
||
try {
|
||
const response = await fetch('/api/danmaku?limit=100')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setDanmakus(data.reverse())
|
||
setTimeout(scrollToBottom, 100)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch danmakus:', error)
|
||
}
|
||
}
|
||
|
||
fetchDanmakus()
|
||
|
||
// 轮询新弹幕
|
||
const interval = setInterval(async () => {
|
||
try {
|
||
const response = await fetch('/api/danmaku?limit=1')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
if (data.length > 0 && data[0].id !== danmakus[danmakus.length - 1]?.id) {
|
||
setDanmakus(prev => [...prev, data[0]])
|
||
setTimeout(scrollToBottom, 100)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to poll danmakus:', error)
|
||
}
|
||
}, 2000)
|
||
|
||
return () => clearInterval(interval)
|
||
}, [isOpen])
|
||
|
||
// 发送弹幕
|
||
const handleSend = async () => {
|
||
if (!content.trim() || sending) return
|
||
|
||
setSending(true)
|
||
try {
|
||
const response = await fetch('/api/danmaku', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ content: content.trim() }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const newDanmaku = await response.json()
|
||
setDanmakus(prev => [...prev, newDanmaku])
|
||
setContent('')
|
||
setTimeout(scrollToBottom, 100)
|
||
} else {
|
||
const error = await response.json()
|
||
showToast(error.error || '发送失败', { variant: 'error' })
|
||
}
|
||
} catch (error) {
|
||
console.error('Send danmaku error:', error)
|
||
showToast('发送失败,请稍后重试', { variant: 'error' })
|
||
} finally {
|
||
setSending(false)
|
||
}
|
||
}
|
||
|
||
// 点击外部关闭
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||
setIsOpen(false)
|
||
}
|
||
}
|
||
|
||
if (isOpen) {
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside)
|
||
}
|
||
}, [isOpen])
|
||
|
||
if (!session) return null
|
||
|
||
return (
|
||
<div ref={containerRef} className="fixed bottom-6 right-6 z-50">
|
||
{/* 弹幕对话框 */}
|
||
{isOpen && (
|
||
<div className="absolute bottom-16 right-0 w-96 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||
{/* 头部 */}
|
||
<div className="px-4 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-semibold text-lg flex items-center gap-2">
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||
</svg>
|
||
全站弹幕(匿名)
|
||
</h3>
|
||
<button
|
||
onClick={() => setIsOpen(false)}
|
||
className="hover:bg-white/20 rounded-full p-1 transition-colors"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 弹幕列表 */}
|
||
<div className="h-96 overflow-y-auto p-4 space-y-3 bg-gray-50 dark:bg-gray-900">
|
||
{danmakus.length === 0 ? (
|
||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||
</svg>
|
||
<p>暂无弹幕,快来发送第一条吧!</p>
|
||
</div>
|
||
) : (
|
||
danmakus.map((danmaku) => (
|
||
<div
|
||
key={danmaku.id}
|
||
className="flex gap-3 animate-fade-in"
|
||
>
|
||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-purple-400 flex items-center justify-center text-white font-semibold text-sm">
|
||
{danmaku.username.charAt(0).toUpperCase()}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="font-medium text-gray-900 dark:text-white text-sm">
|
||
{danmaku.username}
|
||
</span>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||
{new Date(danmaku.createdAt).toLocaleTimeString('zh-CN', {
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</span>
|
||
</div>
|
||
<p className="text-gray-700 dark:text-gray-300 text-sm break-words">
|
||
{danmaku.content}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{/* 输入框 */}
|
||
<div className="p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={content}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
onKeyPress={(e) => {
|
||
if (e.key === 'Enter' && !sending) {
|
||
handleSend()
|
||
}
|
||
}}
|
||
placeholder="匿名发送弹幕..."
|
||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none text-sm"
|
||
maxLength={100}
|
||
disabled={sending}
|
||
/>
|
||
<button
|
||
onClick={handleSend}
|
||
disabled={!content.trim() || sending}
|
||
className="px-4 py-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-lg hover:from-blue-600 hover:to-purple-600 transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium text-sm"
|
||
>
|
||
{sending ? '发送中...' : '发送'}
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||
{content.length}/100 · 按Enter快速发送 · 匿名发送,保护隐私
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 悬浮按钮 */}
|
||
<button
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
className={`w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all transform hover:scale-110 ${
|
||
isOpen
|
||
? 'bg-gray-600 hover:bg-gray-700'
|
||
: 'bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600'
|
||
} text-white flex items-center justify-center`}
|
||
>
|
||
{isOpen ? (
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
) : (
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
|
||
<style jsx>{`
|
||
@keyframes fade-in {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(10px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.animate-fade-in {
|
||
animation: fade-in 0.3s ease-out;
|
||
}
|
||
`}</style>
|
||
|
||
{/* 统一的Toast提示 */}
|
||
{toast && toast.show && (
|
||
<ToastBubble message={toast.message} variant={toast.variant} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|