- GearParentFlowViewer: React Flow 기반 인터랙티브 흐름도 - gear-parent-flow.html: standalone entry point - vite.config.ts: multi-entry 빌드 (main + gearParentFlow) - App.tsx: FLOW 링크 추가 - @xyflow/react 의존성 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useReplay } from './hooks/useReplay';
|
|
import { useMonitor } from './hooks/useMonitor';
|
|
import { useLocalStorage } from './hooks/useLocalStorage';
|
|
import type { AppMode } from './types';
|
|
import { useTheme } from './hooks/useTheme';
|
|
import { useAuth } from './hooks/useAuth';
|
|
import { useTranslation } from 'react-i18next';
|
|
import LoginPage from './components/auth/LoginPage';
|
|
import CollectorMonitor from './components/common/CollectorMonitor';
|
|
import { SharedFilterProvider } from './contexts/SharedFilterContext';
|
|
import { FontScaleProvider } from './contexts/FontScaleContext';
|
|
import { SymbolScaleProvider } from './contexts/SymbolScaleContext';
|
|
import { IranDashboard } from './components/iran/IranDashboard';
|
|
import { KoreaDashboard } from './components/korea/KoreaDashboard';
|
|
import './App.css';
|
|
|
|
function App() {
|
|
const { user, isLoading: authLoading, isAuthenticated, login, devLogin, logout } = useAuth();
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div
|
|
className="flex min-h-screen items-center justify-center"
|
|
style={{ backgroundColor: 'var(--kcg-bg)', color: 'var(--kcg-muted)' }}
|
|
>
|
|
Loading...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
return <LoginPage onGoogleLogin={login} onDevLogin={devLogin} />;
|
|
}
|
|
|
|
return <AuthenticatedApp user={user} onLogout={logout} />;
|
|
}
|
|
|
|
interface AuthenticatedAppProps {
|
|
user: { email: string; name: string; picture?: string } | null;
|
|
onLogout: () => Promise<void>;
|
|
}
|
|
|
|
function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|
const [appMode, setAppMode] = useState<AppMode>('live');
|
|
const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran');
|
|
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
|
|
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
|
|
|
|
// 1시간마다 전체 데이터 강제 리프레시
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
useEffect(() => {
|
|
const HOUR_MS = 3600_000;
|
|
const interval = setInterval(() => setRefreshKey(k => k + 1), HOUR_MS);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const replay = useReplay();
|
|
const monitor = useMonitor();
|
|
const { theme, toggleTheme } = useTheme();
|
|
const { t, i18n } = useTranslation();
|
|
const toggleLang = useCallback(() => {
|
|
i18n.changeLanguage(i18n.language === 'ko' ? 'en' : 'ko');
|
|
}, [i18n]);
|
|
|
|
const isLive = appMode === 'live';
|
|
const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime;
|
|
|
|
return (
|
|
<FontScaleProvider>
|
|
<SymbolScaleProvider>
|
|
<SharedFilterProvider>
|
|
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
|
<header className="app-header">
|
|
{/* Dashboard Tabs */}
|
|
<div className="dash-tabs">
|
|
<button
|
|
type="button"
|
|
className={`dash-tab ${dashboardTab === 'iran' ? 'active' : ''}`}
|
|
onClick={() => setDashboardTab('iran')}
|
|
>
|
|
<span className="dash-tab-flag">🇮🇷</span>
|
|
{t('tabs.iran')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
|
|
onClick={() => setDashboardTab('korea')}
|
|
>
|
|
<span className="dash-tab-flag">🇰🇷</span>
|
|
{t('tabs.korea')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* 탭별 모드/필터 영역 — 각 대시보드가 headerSlot으로 렌더링 */}
|
|
<div id="dashboard-header-slot" />
|
|
|
|
<div className="header-info">
|
|
<div id="dashboard-counts-slot" />
|
|
<div className="header-toggles">
|
|
<button
|
|
type="button"
|
|
className="header-toggle-btn"
|
|
onClick={() => setShowCollectorMonitor(v => !v)}
|
|
title="수집기 모니터링"
|
|
>
|
|
MON
|
|
</button>
|
|
<a
|
|
className="header-toggle-btn"
|
|
href="/gear-parent-flow.html"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title="어구 모선 추적 흐름도"
|
|
>
|
|
FLOW
|
|
</a>
|
|
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
|
|
{i18n.language === 'ko' ? 'KO' : 'EN'}
|
|
</button>
|
|
<button type="button" className="header-toggle-btn" onClick={toggleTheme} title="Theme">
|
|
{theme === 'dark' ? '🌙' : '☀️'}
|
|
</button>
|
|
</div>
|
|
<div className="header-status">
|
|
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
|
|
{isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')}
|
|
</div>
|
|
{user && (
|
|
<div className="header-user">
|
|
{user.picture && (
|
|
<img src={user.picture} alt="" className="header-user-avatar" referrerPolicy="no-referrer" />
|
|
)}
|
|
<span className="header-user-name">{user.name}</span>
|
|
<button type="button" className="header-toggle-btn" onClick={onLogout} title="Logout">
|
|
⏻
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{dashboardTab === 'iran' && (
|
|
<IranDashboard
|
|
currentTime={currentTime}
|
|
isLive={isLive}
|
|
refreshKey={refreshKey}
|
|
replay={replay}
|
|
monitor={monitor}
|
|
timeZone={timeZone}
|
|
onTimeZoneChange={setTimeZone}
|
|
appMode={appMode}
|
|
onAppModeChange={setAppMode}
|
|
/>
|
|
)}
|
|
|
|
{dashboardTab === 'korea' && (
|
|
<KoreaDashboard
|
|
currentTime={currentTime}
|
|
isLive={isLive}
|
|
refreshKey={refreshKey}
|
|
replay={replay}
|
|
monitor={monitor}
|
|
timeZone={timeZone}
|
|
onTimeZoneChange={setTimeZone}
|
|
/>
|
|
)}
|
|
|
|
{showCollectorMonitor && (
|
|
<CollectorMonitor onClose={() => setShowCollectorMonitor(false)} />
|
|
)}
|
|
</div>
|
|
</SharedFilterProvider>
|
|
</SymbolScaleProvider>
|
|
</FontScaleProvider>
|
|
);
|
|
}
|
|
|
|
export default App;
|