234 lines
7.9 KiB
TypeScript
234 lines
7.9 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useSpaceData } from './hooks/useSpaceData';
|
|
import { useHistoricalData } from './hooks/useHistoricalData';
|
|
import { useTrajectory } from './hooks/useTrajectory';
|
|
import { useScreenshot } from './hooks/useScreenshot';
|
|
import { Header } from './components/Header';
|
|
import { Scene } from './components/Scene';
|
|
import { ProbeList } from './components/ProbeList';
|
|
import { TimelineController } from './components/TimelineController';
|
|
import { Loading } from './components/Loading';
|
|
import { InterstellarTicker } from './components/InterstellarTicker';
|
|
import { ControlPanel } from './components/ControlPanel';
|
|
import { AuthModal } from './components/AuthModal';
|
|
import { MessageBoard } from './components/MessageBoard';
|
|
import { auth } from './utils/auth';
|
|
import type { CelestialBody } from './types';
|
|
import { useToast } from './contexts/ToastContext';
|
|
|
|
// Timeline configuration - will be fetched from backend later
|
|
const TIMELINE_DAYS = 30; // Total days in timeline range
|
|
|
|
const PREFS_KEY = 'cosmo_preferences';
|
|
|
|
function App() {
|
|
const navigate = useNavigate();
|
|
const toast = useToast();
|
|
|
|
// Load preferences
|
|
const [isTimelineMode, setIsTimelineMode] = useState(false); // Usually not persisted
|
|
const [showOrbits, setShowOrbits] = useState(true);
|
|
const [isSoundOn, setIsSoundOn] = useState(false);
|
|
const [showMessageBoard, setShowMessageBoard] = useState(false);
|
|
|
|
// Initialize state from localStorage
|
|
useEffect(() => {
|
|
const savedPrefs = localStorage.getItem(PREFS_KEY);
|
|
if (savedPrefs) {
|
|
try {
|
|
const prefs = JSON.parse(savedPrefs);
|
|
if (prefs.showOrbits !== undefined) setShowOrbits(prefs.showOrbits);
|
|
if (prefs.isSoundOn !== undefined) setIsSoundOn(prefs.isSoundOn);
|
|
} catch (e) {
|
|
console.error('Failed to parse preferences:', e);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Persist preferences
|
|
useEffect(() => {
|
|
const prefs = {
|
|
showOrbits,
|
|
isSoundOn,
|
|
};
|
|
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
|
|
}, [showOrbits, isSoundOn]);
|
|
|
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
|
const { takeScreenshot } = useScreenshot();
|
|
const [resetTrigger, setResetTrigger] = useState(0);
|
|
|
|
// Auth state
|
|
const [user, setUser] = useState<any>(auth.getUser());
|
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
|
|
|
// 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);
|
|
|
|
const bodies = isTimelineMode ? historicalBodies : realTimeBodies;
|
|
const loading = isTimelineMode ? historicalLoading : realTimeLoading;
|
|
const error = isTimelineMode ? historicalError : realTimeError;
|
|
|
|
const [selectedBody, setSelectedBody] = useState<CelestialBody | null>(null);
|
|
const { trajectoryPositions } = useTrajectory(selectedBody);
|
|
|
|
// Handle time change from timeline controller
|
|
const handleTimeChange = useCallback((date: Date) => {
|
|
setSelectedDate(date);
|
|
}, []);
|
|
|
|
// Toggle timeline mode
|
|
const toggleTimelineMode = useCallback(() => {
|
|
setIsTimelineMode((prev) => !prev);
|
|
if (!isTimelineMode) {
|
|
// Entering timeline mode, set initial date to now (will play backward)
|
|
setSelectedDate(new Date());
|
|
} else {
|
|
setSelectedDate(null);
|
|
}
|
|
}, [isTimelineMode]);
|
|
|
|
// Filter probes and planets from all bodies
|
|
const probes = bodies.filter((b) => b.type === 'probe');
|
|
const planets = bodies.filter((b) =>
|
|
b.type === 'planet' || b.type === 'dwarf_planet' || b.type === 'satellite' || b.type === 'comet'
|
|
);
|
|
|
|
const handleBodySelect = (body: CelestialBody | null) => {
|
|
setSelectedBody(body);
|
|
};
|
|
|
|
// Screenshot handler with auth check
|
|
const handleScreenshot = useCallback(() => {
|
|
if (!user) {
|
|
toast.warning('请先登录以拍摄宇宙快照');
|
|
setShowAuthModal(true);
|
|
return;
|
|
}
|
|
// Use username or full_name or fallback
|
|
const nickname = user.full_name || user.username || 'Explorer';
|
|
takeScreenshot(nickname);
|
|
}, [user, takeScreenshot]);
|
|
|
|
// Auth handlers
|
|
const handleLoginSuccess = (userData: any) => {
|
|
setUser(userData);
|
|
setShowAuthModal(false);
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
auth.logout();
|
|
setUser(null);
|
|
};
|
|
|
|
// Only show full screen loading when we have no data
|
|
// This prevents flashing when timeline is playing and fetching new data
|
|
if (loading && bodies.length === 0) {
|
|
return <Loading />;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center bg-black text-white">
|
|
<div className="text-center">
|
|
<h1 className="text-2xl font-bold mb-4">数据加载失败</h1>
|
|
<p className="text-red-400">{error}</p>
|
|
<p className="mt-4 text-sm text-gray-400">
|
|
请确保后端 API 运行在 http://localhost:8000
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full h-full relative">
|
|
{/* Header with simplified branding and User Auth */}
|
|
<Header
|
|
bodyCount={bodies.filter(b => b.is_active !== false).length}
|
|
selectedBodyName={selectedBody?.name}
|
|
user={user}
|
|
onOpenAuth={() => setShowAuthModal(true)}
|
|
onLogout={handleLogout}
|
|
onNavigateToAdmin={() => navigate('/admin')}
|
|
/>
|
|
|
|
{/* Right Control Panel */}
|
|
<ControlPanel
|
|
isTimelineMode={isTimelineMode}
|
|
onToggleTimeline={toggleTimelineMode}
|
|
showOrbits={showOrbits}
|
|
onToggleOrbits={() => setShowOrbits(!showOrbits)}
|
|
isSoundOn={isSoundOn}
|
|
onToggleSound={() => setIsSoundOn(!isSoundOn)}
|
|
showMessageBoard={showMessageBoard}
|
|
onToggleMessageBoard={() => setShowMessageBoard(!showMessageBoard)}
|
|
onScreenshot={handleScreenshot}
|
|
/>
|
|
|
|
{/* Auth Modal */}
|
|
<AuthModal
|
|
isOpen={showAuthModal}
|
|
onClose={() => setShowAuthModal(false)}
|
|
onLoginSuccess={handleLoginSuccess}
|
|
/>
|
|
|
|
{/* Message Board */}
|
|
<MessageBoard
|
|
open={showMessageBoard}
|
|
onClose={() => setShowMessageBoard(false)}
|
|
/>
|
|
|
|
{/* Probe List Sidebar */}
|
|
<ProbeList
|
|
probes={probes}
|
|
planets={planets}
|
|
onBodySelect={handleBodySelect}
|
|
selectedBody={selectedBody}
|
|
onResetCamera={() => setResetTrigger(prev => prev + 1)}
|
|
/>
|
|
|
|
{/* 3D Scene */}
|
|
<Scene
|
|
bodies={bodies}
|
|
selectedBody={selectedBody}
|
|
trajectoryPositions={trajectoryPositions}
|
|
showOrbits={showOrbits}
|
|
onBodySelect={handleBodySelect}
|
|
resetTrigger={resetTrigger}
|
|
/>
|
|
|
|
{/* Timeline Controller */}
|
|
{isTimelineMode && (
|
|
<TimelineController
|
|
onTimeChange={handleTimeChange}
|
|
maxDate={new Date()} // Start point (now)
|
|
minDate={new Date(Date.now() - TIMELINE_DAYS * 24 * 60 * 60 * 1000)} // End point (past)
|
|
/>
|
|
)}
|
|
|
|
{/* Interstellar Ticker Sound (Controlled) */}
|
|
<InterstellarTicker isPlaying={isSoundOn} />
|
|
|
|
{/* Instructions overlay (Only show when exploring freely) */}
|
|
{!selectedBody && (
|
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-30 text-white text-xs bg-black/50 backdrop-blur-sm border border-white/10 px-4 py-2 rounded-full flex items-center gap-4 pointer-events-none">
|
|
<div className="flex items-center gap-2">
|
|
<span className="bg-white/20 p-1 rounded text-[10px]">左键</span> 旋转
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="bg-white/20 p-1 rounded text-[10px]">右键</span> 平移
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="bg-white/20 p-1 rounded text-[10px]">滚轮</span> 缩放
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|