Compare commits

...

1 커밋

작성자 SHA1 메시지 날짜
5591ed5504 feat(announcement): 공지 팝업 모듈 + Ocean 기본값 수정
- features/announcement/ 자체 완결 블록 (타입, 상수, 훅, 모달 UI)
- useAnnouncementPopup: lastSeenAnnouncementId 기반 계정별 1회 표시
- AnnouncementModal: 업데이트 안내 (Ocean 맵/자유시점/선박사진)
- Ocean DEFAULT_OCEAN_MAP_SETTINGS: depthStops 빈 배열 (네이티브 색상 유지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:40:31 +09:00
10개의 변경된 파일335개의 추가작업 그리고 41개의 파일을 삭제

파일 보기

@ -18,3 +18,4 @@
@import "./styles/components/auth.css"; @import "./styles/components/auth.css";
@import "./styles/components/weather.css"; @import "./styles/components/weather.css";
@import "./styles/components/weather-overlay.css"; @import "./styles/components/weather-overlay.css";
@import "./styles/components/announcement.css";

파일 보기

@ -0,0 +1,129 @@
/* ── Announcement modal ─────────────────────────────────────────── */
.announcement-modal {
position: fixed;
inset: 0;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
}
.announcement-modal__content {
position: relative;
width: min(88vw, 480px);
max-height: min(85vh, 560px);
display: flex;
flex-direction: column;
background: var(--wing-glass-dense);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.announcement-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--wing-subtle);
}
.announcement-modal__title {
font-size: 14px;
font-weight: 700;
color: var(--text);
}
.announcement-modal__close {
background: none;
border: none;
color: var(--muted);
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
line-height: 1;
}
.announcement-modal__close:hover {
color: var(--text);
}
.announcement-modal__body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.announcement-modal__section {
margin-bottom: 16px;
}
.announcement-modal__section:last-child {
margin-bottom: 0;
}
.announcement-modal__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.announcement-modal__section-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
}
.announcement-modal__date {
font-size: 10px;
color: var(--muted);
}
.announcement-modal__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.announcement-modal__item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 8px 10px;
background: var(--wing-card-alpha);
border: 1px solid var(--wing-subtle);
border-radius: 8px;
}
.announcement-modal__icon {
font-size: 18px;
flex-shrink: 0;
line-height: 1.2;
}
.announcement-modal__item-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
margin-bottom: 2px;
}
.announcement-modal__item-desc {
font-size: 10px;
color: var(--muted);
line-height: 1.5;
}
.announcement-modal__footer {
display: flex;
justify-content: center;
padding: 12px 16px;
border-top: 1px solid var(--wing-subtle);
}

파일 보기

@ -0,0 +1,34 @@
import type { Announcement } from '../model/types';
/**
* .
* , API로 api/ .
* id는 ( id + 1).
*/
export const ANNOUNCEMENTS: Announcement[] = [
{
id: 1,
title: 'Wing Fleet Dashboard 업데이트',
date: '2026-02-20',
items: [
{
icon: '🌊',
title: 'Ocean 전용 지도',
description: 'MapTiler Ocean 스타일 기반의 해양 특화 지도가 추가되었습니다. 수심 색상, 등심선, 해저 지형명을 설정 패널에서 세부 조정할 수 있습니다.',
},
{
icon: '🎥',
title: '자유시점 모드',
description: '지도 표시 설정의 "자유 시점" 토글로 카메라 회전/틸트를 허용할 수 있습니다. 평면지도와 3D지도에서 각각 독립적으로 설정됩니다.',
},
{
icon: '📷',
title: '선박 사진 조회',
description: '지도 위 선박을 클릭하면 사진이 있는 선박의 이미지를 갤러리로 확인할 수 있습니다.',
},
],
},
];
/** 현재 최신 공지 ID */
export const LATEST_ANNOUNCEMENT_ID = ANNOUNCEMENTS[ANNOUNCEMENTS.length - 1]?.id ?? 0;

파일 보기

@ -0,0 +1,33 @@
import { useCallback, useMemo } from 'react';
import { usePersistedState } from '../../../shared/hooks';
import { ANNOUNCEMENTS, LATEST_ANNOUNCEMENT_ID } from '../data/announcements';
/**
* .
*
* localStorage : `wing:${userId}:lastSeenAnnouncementId`
* (LATEST_ANNOUNCEMENT_ID) .
*/
export function useAnnouncementPopup(userId: number | null) {
const [lastSeenId, setLastSeenId] = usePersistedState<number>(
userId,
'lastSeenAnnouncementId',
0,
);
/** 미확인 공지가 있는지 여부 */
const hasUnread = LATEST_ANNOUNCEMENT_ID > lastSeenId;
/** 미확인 공지 목록 */
const unreadAnnouncements = useMemo(
() => ANNOUNCEMENTS.filter((a) => a.id > lastSeenId),
[lastSeenId],
);
/** "확인" 버튼 클릭 시 호출 — 최신 공지 ID를 저장 */
const acknowledge = useCallback(() => {
setLastSeenId(LATEST_ANNOUNCEMENT_ID);
}, [setLastSeenId]);
return { hasUnread, unreadAnnouncements, acknowledge };
}

파일 보기

@ -0,0 +1,4 @@
export type { Announcement, AnnouncementItem } from './model/types';
export { ANNOUNCEMENTS, LATEST_ANNOUNCEMENT_ID } from './data/announcements';
export { useAnnouncementPopup } from './hooks/useAnnouncementPopup';
export { AnnouncementModal } from './ui/AnnouncementModal';

파일 보기

@ -0,0 +1,21 @@
/** 공지 항목 하나 */
export interface AnnouncementItem {
/** 아이콘 (emoji) */
icon: string;
/** 항목 제목 */
title: string;
/** 항목 상세 설명 */
description?: string;
}
/** 공지 전체 구조 */
export interface Announcement {
/** 고유 식별자 — 단조 증가 (향후 서버 PK와 매핑) */
id: number;
/** 공지 제목 (모달 헤더) */
title: string;
/** 표시용 날짜 */
date: string;
/** 공지 항목 리스트 */
items: AnnouncementItem[];
}

파일 보기

@ -0,0 +1,66 @@
import { useEffect } from 'react';
import { Button } from '@wing/ui';
import type { Announcement } from '../model/types';
interface AnnouncementModalProps {
announcements: Announcement[];
onConfirm: () => void;
}
export function AnnouncementModal({ announcements, onConfirm }: AnnouncementModalProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onConfirm]);
return (
<div className="announcement-modal" onClick={onConfirm}>
<div className="announcement-modal__content" onClick={(e) => e.stopPropagation()}>
<div className="announcement-modal__header">
<span className="announcement-modal__title"> </span>
<button
className="announcement-modal__close"
onClick={onConfirm}
type="button"
aria-label="닫기"
>
</button>
</div>
<div className="announcement-modal__body">
{announcements.map((announcement) => (
<div key={announcement.id} className="announcement-modal__section">
<div className="announcement-modal__section-header">
<span className="announcement-modal__section-title">{announcement.title}</span>
<span className="announcement-modal__date">{announcement.date}</span>
</div>
<ul className="announcement-modal__list">
{announcement.items.map((item, idx) => (
<li key={idx} className="announcement-modal__item">
<span className="announcement-modal__icon">{item.icon}</span>
<div>
<div className="announcement-modal__item-title">{item.title}</div>
{item.description && (
<div className="announcement-modal__item-desc">{item.description}</div>
)}
</div>
</li>
))}
</ul>
</div>
))}
</div>
<div className="announcement-modal__footer">
<Button variant="primary" size="md" onClick={onConfirm}>
</Button>
</div>
</div>
</div>
);
}

파일 보기

@ -38,40 +38,33 @@ export interface OceanMapSettings {
labelLanguage: OceanLabelLanguage; labelLanguage: OceanLabelLanguage;
} }
/**
* Ocean .
* depthStops가 Ocean .
* depthStops에 .
*/
export const DEFAULT_OCEAN_MAP_SETTINGS: OceanMapSettings = { export const DEFAULT_OCEAN_MAP_SETTINGS: OceanMapSettings = {
depthStops: [ // 빈 배열 = Ocean 스타일 네이티브 색상 사용 (커스텀 안 함)
{ depth: -11000, color: '#000308' }, depthStops: [],
{ depth: -8000, color: '#010610' }, depthOpacity: 1,
{ depth: -6000, color: '#020a18' },
{ depth: -4000, color: '#030c1c' },
{ depth: -2000, color: '#041022' },
{ depth: -1000, color: '#051529' },
{ depth: -500, color: '#061a30' },
{ depth: -200, color: '#071f36' },
{ depth: -100, color: '#08263d' },
{ depth: -50, color: '#0e3d5e' },
{ depth: -20, color: '#145578' },
{ depth: 0, color: '#2097a6' },
],
depthOpacity: 0.88,
contourVisible: true, contourVisible: true,
contourColor: '#ffffff', contourColor: '#4a90c2',
contourOpacity: 0.2, contourOpacity: 0.15,
contourWidth: 0.8, contourWidth: 0.6,
depthLabelsVisible: true, depthLabelsVisible: true,
depthLabelColor: '#e2e8f0', depthLabelColor: '#a0c4e8',
depthLabelSize: 'medium', depthLabelSize: 'medium',
hillshadeVisible: true, hillshadeVisible: true,
hillshadeExaggeration: 0.5, hillshadeExaggeration: 0.5,
hillshadeColor: '#000020', hillshadeColor: '#0a1628',
landformLabelsVisible: true, landformLabelsVisible: true,
landformLabelColor: '#94a3b8', landformLabelColor: '#7ea8c8',
backgroundColor: '#010610', backgroundColor: '#0b1a2e',
labelLanguage: 'ko', labelLanguage: 'ko',
}; };

파일 보기

@ -122,7 +122,12 @@ export function OceanMapSettingsPanel({ value, onChange }: OceanMapSettingsPanel
</span> </span>
</div> </div>
{DISPLAY_DEPTH_INDICES.map((idx) => { {value.depthStops.length === 0 ? (
<div className="ms-row" style={{ fontSize: 9, color: 'var(--muted)' }}>
</div>
) : (
DISPLAY_DEPTH_INDICES.map((idx) => {
const stop = value.depthStops[idx]; const stop = value.depthStops[idx];
if (!stop) return null; if (!stop) return null;
const isEdge = idx === 0 || idx === value.depthStops.length - 1; const isEdge = idx === 0 || idx === value.depthStops.length - 1;
@ -140,7 +145,8 @@ export function OceanMapSettingsPanel({ value, onChange }: OceanMapSettingsPanel
<span className="ms-hex">{stop.color}</span> <span className="ms-hex">{stop.color}</span>
</div> </div>
); );
})} })
)}
</div> </div>
{/* ── Depth opacity ─────────────────────────── */} {/* ── Depth opacity ─────────────────────────── */}

파일 보기

@ -29,6 +29,7 @@ import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel"; import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel"; import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel";
import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement";
import { import {
buildLegacyHitMap, buildLegacyHitMap,
computeCountsByType, computeCountsByType,
@ -62,6 +63,9 @@ export function DashboardPage() {
const uid = user?.id ?? null; const uid = user?.id ?? null;
const isDevMode = user?.name?.includes('(DEV)') ?? false; const isDevMode = user?.name?.includes('(DEV)') ?? false;
// ── Announcement popup ──
const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid);
// ── Data fetching ── // ── Data fetching ──
const { data: zones, error: zonesError } = useZones(); const { data: zones, error: zonesError } = useZones();
const { data: legacyData, error: legacyError } = useLegacyVessels(); const { data: legacyData, error: legacyError } = useLegacyVessels();
@ -435,6 +439,9 @@ export function DashboardPage() {
) : selectedTarget ? ( ) : selectedTarget ? (
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} onOpenImageModal={handlePanelOpenImageModal} /> <AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} onOpenImageModal={handlePanelOpenImageModal} />
) : null} ) : null}
{hasUnread && (
<AnnouncementModal announcements={unreadAnnouncements} onConfirm={acknowledge} />
)}
{imageModal && ( {imageModal && (
<ShipImageModal <ShipImageModal
images={imageModal.images} images={imageModal.images}