feat: Replace floating danmaku with a terminal-style Message Board component

main
mula.liu 2025-12-01 00:33:06 +08:00
parent 4b214980cf
commit 33ac56aec1
4 changed files with 283 additions and 98 deletions

View File

@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { message, Modal, Input } from 'antd';
import { message } from 'antd';
import { useSpaceData } from './hooks/useSpaceData';
import { useHistoricalData } from './hooks/useHistoricalData';
import { useTrajectory } from './hooks/useTrajectory';
@ -13,9 +13,8 @@ import { Loading } from './components/Loading';
import { InterstellarTicker } from './components/InterstellarTicker';
import { ControlPanel } from './components/ControlPanel';
import { AuthModal } from './components/AuthModal';
import { DanmakuLayer } from './components/DanmakuLayer';
import { MessageBoard } from './components/MessageBoard';
import { auth } from './utils/auth';
import { request } from './utils/request';
import type { CelestialBody } from './types';
// Timeline configuration - will be fetched from backend later
@ -30,7 +29,7 @@ function App() {
const [isTimelineMode, setIsTimelineMode] = useState(false); // Usually not persisted
const [showOrbits, setShowOrbits] = useState(true);
const [isSoundOn, setIsSoundOn] = useState(false);
const [showDanmaku, setShowDanmaku] = useState(true);
const [showMessageBoard, setShowMessageBoard] = useState(false);
// Initialize state from localStorage
useEffect(() => {
@ -40,7 +39,6 @@ function App() {
const prefs = JSON.parse(savedPrefs);
if (prefs.showOrbits !== undefined) setShowOrbits(prefs.showOrbits);
if (prefs.isSoundOn !== undefined) setIsSoundOn(prefs.isSoundOn);
if (prefs.showDanmaku !== undefined) setShowDanmaku(prefs.showDanmaku);
} catch (e) {
console.error('Failed to parse preferences:', e);
}
@ -52,10 +50,9 @@ function App() {
const prefs = {
showOrbits,
isSoundOn,
showDanmaku
};
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
}, [showOrbits, isSoundOn, showDanmaku]);
}, [showOrbits, isSoundOn]);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const { takeScreenshot } = useScreenshot();
@ -65,10 +62,6 @@ function App() {
const [user, setUser] = useState<any>(auth.getUser());
const [showAuthModal, setShowAuthModal] = useState(false);
// Danmaku state
const [isDanmakuInputVisible, setIsDanmakuInputVisible] = useState(false);
const [danmakuText, setDanmakuText] = useState('');
// Use real-time data or historical data based on mode
const { bodies: realTimeBodies, loading: realTimeLoading, error: realTimeError } = useSpaceData();
const { bodies: historicalBodies, loading: historicalLoading, error: historicalError } = useHistoricalData(selectedDate);
@ -118,29 +111,6 @@ function App() {
takeScreenshot(nickname);
}, [user, takeScreenshot]);
// Danmaku send handler
const handleSendDanmaku = async () => {
if (!danmakuText.trim()) return;
if (danmakuText.length > 20) {
message.warning("弹幕内容不能超过20字");
return;
}
if (!user) {
message.warning("请先登录");
setShowAuthModal(true);
return;
}
try {
await request.post('/danmaku/send', { text: danmakuText });
message.success("发送成功");
setDanmakuText('');
setIsDanmakuInputVisible(false);
} catch (e) {
console.error(e);
message.error("发送失败");
}
};
// Auth handlers
const handleLoginSuccess = (userData: any) => {
setUser(userData);
@ -184,9 +154,6 @@ function App() {
onNavigateToAdmin={() => navigate('/admin')}
/>
{/* Danmaku Layer */}
<DanmakuLayer enabled={showDanmaku} />
{/* Right Control Panel */}
<ControlPanel
isTimelineMode={isTimelineMode}
@ -195,16 +162,8 @@ function App() {
onToggleOrbits={() => setShowOrbits(!showOrbits)}
isSoundOn={isSoundOn}
onToggleSound={() => setIsSoundOn(!isSoundOn)}
showDanmaku={showDanmaku}
onToggleDanmaku={() => setShowDanmaku(!showDanmaku)}
onOpenDanmakuInput={() => {
if (!user) {
message.warning("请先登录");
setShowAuthModal(true);
} else {
setIsDanmakuInputVisible(true);
}
}}
showMessageBoard={showMessageBoard}
onToggleMessageBoard={() => setShowMessageBoard(!showMessageBoard)}
onScreenshot={handleScreenshot}
/>
@ -215,26 +174,11 @@ function App() {
onLoginSuccess={handleLoginSuccess}
/>
{/* Danmaku Input Modal */}
<Modal
title="发送星际弹幕"
open={isDanmakuInputVisible}
onOk={handleSendDanmaku}
onCancel={() => setIsDanmakuInputVisible(false)}
okText="发射"
cancelText="取消"
centered
>
<Input
placeholder="输入想说的话 (20字以内)"
maxLength={20}
showCount
value={danmakuText}
onChange={e => setDanmakuText(e.target.value)}
onPressEnter={handleSendDanmaku}
autoFocus
/>
</Modal>
{/* Message Board */}
<MessageBoard
open={showMessageBoard}
onClose={() => setShowMessageBoard(false)}
/>
{/* Probe List Sidebar */}
<ProbeList

View File

@ -6,8 +6,7 @@ import {
MessageSquare,
Eye,
EyeOff,
Camera,
Send
Camera
} from 'lucide-react';
interface ControlPanelProps {
@ -17,9 +16,8 @@ interface ControlPanelProps {
onToggleOrbits: () => void;
isSoundOn: boolean;
onToggleSound: () => void;
showDanmaku: boolean;
onToggleDanmaku: () => void;
onOpenDanmakuInput: () => void;
showMessageBoard: boolean;
onToggleMessageBoard: () => void;
onScreenshot: () => void;
}
@ -30,9 +28,8 @@ export function ControlPanel({
onToggleOrbits,
isSoundOn,
onToggleSound,
showDanmaku,
onToggleDanmaku,
onOpenDanmakuInput,
showMessageBoard,
onToggleMessageBoard,
onScreenshot,
}: ControlPanelProps) {
const buttonClass = (isActive: boolean) => `
@ -80,29 +77,16 @@ export function ControlPanel({
</div>
</button>
{/* Danmaku Toggle */}
<div className="relative flex items-center">
{showDanmaku && (
<button
onClick={onOpenDanmakuInput}
className="mr-2 p-2 rounded-lg bg-white/10 text-gray-300 hover:bg-white/20 border border-white/5 transition-all duration-200 relative group"
>
<Send size={16} />
<div className={tooltipClass}>
</div>
</button>
)}
<button
onClick={onToggleDanmaku}
className={buttonClass(showDanmaku)}
>
<MessageSquare size={20} />
<div className={tooltipClass}>
{showDanmaku ? '关闭弹幕' : '开启弹幕'}
</div>
</button>
</div>
{/* Message Board Toggle */}
<button
onClick={onToggleMessageBoard}
className={buttonClass(showMessageBoard)}
>
<MessageSquare size={20} />
<div className={tooltipClass}>
{showMessageBoard ? '关闭留言板' : '打开留言板'}
</div>
</button>
{/* Screenshot Button */}
<button

View File

@ -0,0 +1,138 @@
import { useState, useEffect, useRef } from 'react';
import { Input, Button, message } from 'antd';
import { Send, MessageSquare } from 'lucide-react';
import { TerminalModal } from './TerminalModal';
import { request } from '../utils/request';
import { auth } from '../utils/auth';
interface Message {
id: string;
uid: string;
username: string;
text: string;
ts: number;
}
interface MessageBoardProps {
open: boolean;
onClose: () => void;
}
export function MessageBoard({ open, onClose }: MessageBoardProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<number | null>(null);
const fetchMessages = async () => {
try {
const { data } = await request.get('/danmaku/list');
setMessages(data.sort((a: Message, b: Message) => a.ts - b.ts));
} catch (err) {
console.error("Failed to fetch messages", err);
}
};
useEffect(() => {
if (open) {
setLoading(true);
fetchMessages().finally(() => setLoading(false));
intervalRef.current = window.setInterval(fetchMessages, 3000);
} else {
clearInterval(intervalRef.current);
}
return () => clearInterval(intervalRef.current);
}, [open]);
useEffect(() => {
if (open) {
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 100);
}
}, [messages, open]);
const handleSend = async () => {
const content = inputValue.trim();
if (!content) return;
const user = auth.getUser();
if (!user) {
message.warning('请先登录');
return;
}
if (content.length > 20) {
message.warning('消息不能超过20字');
return;
}
setSending(true);
try {
await request.post('/danmaku/send', { text: content });
setInputValue('');
await fetchMessages();
} catch (err) {
message.error('发送失败');
} finally {
setSending(false);
}
};
return (
<TerminalModal
open={open}
onClose={onClose}
title={
<div className="flex items-center gap-2 text-[#2ea043] font-mono tracking-wider text-xs">
<MessageSquare size={14} />
INTERSTELLAR COMM LINK // PUBLIC CHANNEL
</div>
}
loading={loading && messages.length === 0}
loadingText="SCANNING FREQUENCIES..."
footer={
<div className="flex gap-2">
<Input
placeholder="Transmitting message... (Max 20 chars)"
maxLength={20}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={handleSend}
className="bg-black border-[#238636] text-[#2ea043] font-mono placeholder:text-[#2ea043]/30 focus:bg-black focus:border-[#2ea043] hover:border-[#2ea043]"
style={{ backgroundColor: '#0d1117', color: '#2ea043' }}
/>
<Button
type="primary"
icon={<Send size={14} />}
loading={sending}
onClick={handleSend}
className="bg-[#238636] border-[#238636] hover:bg-[#2ea043] hover:border-[#2ea043] text-black font-bold font-mono"
>
SEND
</Button>
</div>
}
>
<div className="flex flex-col gap-3">
{messages.map((msg) => (
<div key={msg.id} className="flex flex-col gap-1 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="flex items-center gap-2 text-[10px] opacity-50">
<span className="text-[#2ea043]">{new Date(msg.ts * 1000).toLocaleTimeString()}</span>
<span className="text-cyan-500">{'<'}{msg.username}{'>'}</span>
</div>
<div className="pl-2 border-l-2 border-[#238636]/30 text-[#2ea043] break-words font-bold">
{msg.text}
</div>
</div>
))}
{messages.length === 0 && !loading && (
<div className="text-center opacity-30 py-10 italic text-[#2ea043]">
NO SIGNALS DETECTED
</div>
)}
<div ref={bottomRef} />
</div>
</TerminalModal>
);
}

View File

@ -0,0 +1,119 @@
import { Modal, Spin } from 'antd';
import { X } from 'lucide-react';
import type { ReactNode } from 'react';
interface TerminalModalProps {
open: boolean;
onClose: () => void;
title: ReactNode;
loading?: boolean;
loadingText?: string;
children: ReactNode;
footer?: ReactNode;
width?: number;
}
export function TerminalModal({
open,
onClose,
title,
loading = false,
loadingText = "PROCESSING...",
children,
footer = null,
width = 800
}: TerminalModalProps) {
const terminalStyles = `
.terminal-modal .ant-modal-content {
background-color: #0d1117 !important;
border: 1px solid #238636 !important;
box-shadow: 0 0 30px rgba(35, 134, 54, 0.15) !important;
color: #2ea043 !important;
padding: 0 !important;
overflow: hidden !important;
display: flex;
flex-direction: column;
}
.terminal-modal .ant-modal-body {
background-color: #0d1117 !important;
padding: 0 !important;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.terminal-modal .ant-modal-body .animate-in {
color: #2ea043 !important;
}
.terminal-modal .ant-modal-header {
background-color: #161b22 !important;
border-bottom: 1px solid #238636 !important;
padding: 12px 20px !important;
margin-bottom: 0 !important;
display: flex;
align-items: center;
flex-shrink: 0;
}
.terminal-modal .ant-modal-footer {
background-color: #161b22 !important;
border-top: 1px solid #238636 !important;
padding: 12px 20px !important;
margin-top: 0 !important;
flex-shrink: 0;
}
.terminal-modal .ant-modal-title {
color: #2ea043 !important;
}
.terminal-modal .ant-modal-close {
color: #2ea043 !important;
top: 12px !important;
}
.terminal-modal .ant-modal-close:hover {
background-color: rgba(35, 134, 54, 0.2) !important;
}
/* Scrollbar styling */
.terminal-scroll::-webkit-scrollbar {
width: 8px;
background: #0d1117;
}
.terminal-scroll::-webkit-scrollbar-thumb {
background: #238636;
border-radius: 4px;
}
.terminal-scroll::-webkit-scrollbar-track {
background: #0d1117;
}
`;
return (
<>
<style>{terminalStyles}</style>
<Modal
open={open}
onCancel={onClose}
footer={footer}
width={width}
centered
className="terminal-modal"
title={title}
closeIcon={<X size={18} />}
>
{loading ? (
<div className="h-[60vh] flex items-center justify-center flex-col gap-4 text-[#2ea043]">
<Spin size="large" />
<div className="animate-pulse tracking-widest font-mono">{loadingText}</div>
</div>
) : (
<div className="h-[60vh] flex flex-col bg-[#0d1117]">
<div className="flex-1 overflow-auto terminal-scroll font-mono text-xs p-5">
<div className="animate-in fade-in duration-500">
{children}
</div>
</div>
</div>
)}
</Modal>
</>
);
}