feat: Replace floating danmaku with a terminal-style Message Board component
parent
4b214980cf
commit
33ac56aec1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue