flnxi-Wu/components/DanmakuWidget.tsx
WuYongJie f646b71813
Some checks failed
CI Check / check (push) Has been cancelled
chore: 初始化本地仓库并同步最近改动
2025-11-18 17:50:16 +08:00

252 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useRef } from 'react'
import { useSession } from 'next-auth/react'
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 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()
alert(error.error || '发送失败')
}
} catch (error) {
console.error('Send danmaku error:', error)
alert('发送失败,请稍后重试')
} 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>
</div>
)
}