- frontend/ 폴더로 프론트엔드 전체 이관 - signal-batch API 연동 (한국 선박 위치 데이터) - Tailwind CSS 4 + CSS 변수 테마 토큰 (dark/light) - i18next 다국어 (ko/en) 인프라 + 28개 컴포넌트 적용 - 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 범례) - Google OAuth 로그인 화면 + DEV LOGIN 우회 - 외부 API CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak) - ShipLayer 이미지 탭 전환 (signal-batch / MarineTraffic) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { GeoEvent } from '../types';
|
|
|
|
interface Props {
|
|
events: GeoEvent[];
|
|
currentTime: number;
|
|
startTime: number;
|
|
endTime: number;
|
|
onEventClick: (event: GeoEvent) => void;
|
|
}
|
|
|
|
const KST_OFFSET = 9 * 3600_000;
|
|
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
airstrike: '#ef4444',
|
|
explosion: '#f97316',
|
|
missile_launch: '#eab308',
|
|
intercept: '#3b82f6',
|
|
alert: '#a855f7',
|
|
impact: '#ff0000',
|
|
osint: '#06b6d4',
|
|
};
|
|
|
|
const TYPE_KEYS: Record<string, string> = {
|
|
airstrike: 'event.airstrike',
|
|
explosion: 'event.explosion',
|
|
missile_launch: 'event.missileLaunch',
|
|
intercept: 'event.intercept',
|
|
alert: 'event.alert',
|
|
impact: 'event.impact',
|
|
osint: 'event.osint',
|
|
};
|
|
|
|
const SOURCE_KEYS: Record<string, string> = {
|
|
US: 'source.US',
|
|
IL: 'source.IL',
|
|
IR: 'source.IR',
|
|
proxy: 'source.proxy',
|
|
};
|
|
|
|
interface EventGroup {
|
|
dateKey: string; // "2026-03-01"
|
|
dateLabel: string; // "03/01 (토)"
|
|
events: GeoEvent[];
|
|
}
|
|
|
|
const DAY_NAME_KEYS = [
|
|
'dayNames.sun', 'dayNames.mon', 'dayNames.tue', 'dayNames.wed',
|
|
'dayNames.thu', 'dayNames.fri', 'dayNames.sat',
|
|
];
|
|
|
|
export function EventStrip({ events, currentTime, onEventClick }: Props) {
|
|
const [openDate, setOpenDate] = useState<string | null>(null);
|
|
const { t } = useTranslation();
|
|
|
|
const groups = useMemo(() => {
|
|
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
|
const map = new Map<string, GeoEvent[]>();
|
|
|
|
for (const ev of sorted) {
|
|
const d = new Date(ev.timestamp + KST_OFFSET);
|
|
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
|
|
if (!map.has(key)) map.set(key, []);
|
|
map.get(key)!.push(ev);
|
|
}
|
|
|
|
const result: EventGroup[] = [];
|
|
for (const [dateKey, evs] of map) {
|
|
const d = new Date(evs[0].timestamp + KST_OFFSET);
|
|
const dayName = t(DAY_NAME_KEYS[d.getUTCDay()]);
|
|
const dateLabel = `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')} (${dayName})`;
|
|
result.push({ dateKey, dateLabel, events: evs });
|
|
}
|
|
return result;
|
|
}, [events, t]);
|
|
|
|
// Auto-open the first group if none selected
|
|
const effectiveOpen = openDate ?? (groups.length > 0 ? groups[0].dateKey : null);
|
|
|
|
const formatTimeKST = (ts: number) => {
|
|
const d = new Date(ts + KST_OFFSET);
|
|
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
|
|
};
|
|
|
|
return (
|
|
<div className="event-strip">
|
|
{/* Date tabs */}
|
|
<div className="es-tabs">
|
|
<span className="es-label">STRIKES</span>
|
|
{groups.map(g => {
|
|
const isActive = effectiveOpen === g.dateKey;
|
|
const passedCount = g.events.filter(e => e.timestamp <= currentTime).length;
|
|
return (
|
|
<button
|
|
key={g.dateKey}
|
|
className={`es-tab ${isActive ? 'active' : ''}`}
|
|
onClick={() => setOpenDate(isActive ? null : g.dateKey)}
|
|
>
|
|
<span className="es-tab-date">{g.dateLabel}</span>
|
|
<span className="es-tab-count">{passedCount}/{g.events.length}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Expanded event list for selected date */}
|
|
{effectiveOpen && (() => {
|
|
const group = groups.find(g => g.dateKey === effectiveOpen);
|
|
if (!group) return null;
|
|
return (
|
|
<div className="es-events">
|
|
{group.events.map(ev => {
|
|
const isPast = ev.timestamp <= currentTime;
|
|
const color = TYPE_COLORS[ev.type] || '#888';
|
|
const source = ev.source ? t(SOURCE_KEYS[ev.source] ?? ev.source) : '';
|
|
const typeLabel = t(TYPE_KEYS[ev.type] ?? ev.type);
|
|
|
|
return (
|
|
<button
|
|
key={ev.id}
|
|
className={`es-event ${isPast ? 'past' : 'future'}`}
|
|
style={{ '--dot-color': color } as React.CSSProperties}
|
|
onClick={() => onEventClick(ev)}
|
|
title={ev.description || ev.label}
|
|
>
|
|
<span className="es-dot" />
|
|
<span className="es-time">{formatTimeKST(ev.timestamp)}</span>
|
|
{source && (
|
|
<span className="es-source" style={{ background: color }}>{source}</span>
|
|
)}
|
|
<span className="es-name">{ev.label}</span>
|
|
<span className="es-type">{typeLabel}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
}
|