278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
/**
|
||
* DanmakuWidget 组件
|
||
*
|
||
* 全站弹幕组件
|
||
* 支持匿名发送弹幕,实时显示全站弹幕消息
|
||
* 使用三层架构样式系统,支持深浅色主题
|
||
*
|
||
* @created 2025-01-XX
|
||
* @component
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useState, useEffect, useRef } from 'react'
|
||
import { cn } from '@/lib/ui-styles'
|
||
import Input from '@/components/ui/Input'
|
||
import Button from '@/components/ui/Button'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useDialog } from '@/components/ui/DialogProvider'
|
||
|
||
interface Danmaku {
|
||
id: string
|
||
content: string
|
||
username: string
|
||
createdAt: string
|
||
}
|
||
|
||
export default function DanmakuWidget() {
|
||
const { data: session } = useSession()
|
||
const { alert } = useDialog()
|
||
|
||
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 lastDanmakuIdRef = useRef<string | null>(null) // 用于轮询新消息
|
||
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||
}
|
||
|
||
// 获取弹幕列表:首次加载100条,然后每2秒轮询新消息
|
||
useEffect(() => {
|
||
if (!isOpen) return
|
||
|
||
const fetchDanmakus = async () => {
|
||
try {
|
||
const response = await fetch('/api/danmaku?limit=100')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
// 反转数组,使最新的消息在底部
|
||
const normalized = Array.isArray(data) ? [...data].reverse() : []
|
||
setDanmakus(normalized)
|
||
lastDanmakuIdRef.current = normalized[normalized.length - 1]?.id ?? null
|
||
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()
|
||
const latestId = data[0]?.id
|
||
if (latestId && latestId !== lastDanmakuIdRef.current) {
|
||
lastDanmakuIdRef.current = latestId
|
||
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])
|
||
lastDanmakuIdRef.current = newDanmaku.id
|
||
setContent('')
|
||
setTimeout(scrollToBottom, 100)
|
||
} else {
|
||
const error = await response.json()
|
||
alert({
|
||
title: '发送失败',
|
||
message: error.error || '发送失败',
|
||
variant: 'error',
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.error('Send danmaku error:', error)
|
||
alert({
|
||
title: '发送失败',
|
||
message: '发送失败,请稍后重试',
|
||
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 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900">
|
||
{/* 头部 */}
|
||
<div className="px-4 py-3 border-b border-slate-200 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-semibold text-base 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="rounded-md p-1 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||
aria-label="关闭弹幕"
|
||
>
|
||
<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={cn(
|
||
'h-96 overflow-y-auto p-4 space-y-3',
|
||
'bg-slate-50 dark:bg-slate-900'
|
||
)}>
|
||
{danmakus.length === 0 ? (
|
||
<div className="text-center py-12 text-slate-500 dark:text-slate-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"
|
||
>
|
||
<div className="flex-shrink-0 w-8 h-8 rounded-md bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-700 dark:text-slate-200 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-slate-900 dark:text-white text-sm">
|
||
{danmaku.username}
|
||
</span>
|
||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||
{new Date(danmaku.createdAt).toLocaleTimeString('zh-CN', {
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</span>
|
||
</div>
|
||
<p className="text-slate-700 dark:text-slate-300 text-sm break-words">
|
||
{danmaku.content}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{/* 输入区域 */}
|
||
<div className={cn(
|
||
'p-4 border-t',
|
||
'bg-white dark:bg-slate-800',
|
||
'border-slate-200 dark:border-slate-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="匿名发送弹幕..."
|
||
maxLength={100}
|
||
disabled={sending}
|
||
fullWidth
|
||
className="text-sm"
|
||
/>
|
||
<Button
|
||
onClick={handleSend}
|
||
disabled={!content.trim() || sending}
|
||
variant="primary"
|
||
loading={sending}
|
||
>
|
||
{sending ? '发送中...' : '发送'}
|
||
</Button>
|
||
</div>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
|
||
{content.length}/100 · 按Enter快速发送 · 匿名发送,保护隐私
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 悬浮按钮 */}
|
||
<button
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
className={`w-12 h-12 rounded-lg border shadow-sm transition-colors ${
|
||
isOpen
|
||
? 'border-slate-700 bg-slate-700 text-white hover:bg-slate-800 dark:border-slate-500 dark:bg-slate-700 dark:hover:bg-slate-600'
|
||
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800'
|
||
} flex items-center justify-center`}
|
||
aria-label={isOpen ? '关闭弹幕' : '打开弹幕'}
|
||
>
|
||
{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>
|
||
</div>
|
||
)
|
||
}
|
||
|