cosmo/frontend/src/App.tsx

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;