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>
This commit is contained in:
부모
cd9311944b
커밋
3240f6d348
@ -18,3 +18,4 @@
|
||||
@import "./styles/components/auth.css";
|
||||
@import "./styles/components/weather.css";
|
||||
@import "./styles/components/weather-overlay.css";
|
||||
@import "./styles/components/announcement.css";
|
||||
|
||||
129
apps/web/src/app/styles/components/announcement.css
Normal file
129
apps/web/src/app/styles/components/announcement.css
Normal file
@ -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);
|
||||
}
|
||||
34
apps/web/src/features/announcement/data/announcements.ts
Normal file
34
apps/web/src/features/announcement/data/announcements.ts
Normal file
@ -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 };
|
||||
}
|
||||
4
apps/web/src/features/announcement/index.ts
Normal file
4
apps/web/src/features/announcement/index.ts
Normal file
@ -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';
|
||||
21
apps/web/src/features/announcement/model/types.ts
Normal file
21
apps/web/src/features/announcement/model/types.ts
Normal file
@ -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[];
|
||||
}
|
||||
66
apps/web/src/features/announcement/ui/AnnouncementModal.tsx
Normal file
66
apps/web/src/features/announcement/ui/AnnouncementModal.tsx
Normal file
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ocean 스타일 기본 설정.
|
||||
* depthStops가 비어있으면 Ocean 스타일의 네이티브 수심 색상을 유지한다.
|
||||
* 사용자가 커스텀하면 depthStops에 값이 채워져 적용된다.
|
||||
*/
|
||||
export const DEFAULT_OCEAN_MAP_SETTINGS: OceanMapSettings = {
|
||||
depthStops: [
|
||||
{ depth: -11000, color: '#000308' },
|
||||
{ depth: -8000, color: '#010610' },
|
||||
{ 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,
|
||||
// 빈 배열 = Ocean 스타일 네이티브 색상 사용 (커스텀 안 함)
|
||||
depthStops: [],
|
||||
depthOpacity: 1,
|
||||
|
||||
contourVisible: true,
|
||||
contourColor: '#ffffff',
|
||||
contourOpacity: 0.2,
|
||||
contourWidth: 0.8,
|
||||
contourColor: '#4a90c2',
|
||||
contourOpacity: 0.15,
|
||||
contourWidth: 0.6,
|
||||
|
||||
depthLabelsVisible: true,
|
||||
depthLabelColor: '#e2e8f0',
|
||||
depthLabelColor: '#a0c4e8',
|
||||
depthLabelSize: 'medium',
|
||||
|
||||
hillshadeVisible: true,
|
||||
hillshadeExaggeration: 0.5,
|
||||
hillshadeColor: '#000020',
|
||||
hillshadeColor: '#0a1628',
|
||||
|
||||
landformLabelsVisible: true,
|
||||
landformLabelColor: '#94a3b8',
|
||||
landformLabelColor: '#7ea8c8',
|
||||
|
||||
backgroundColor: '#010610',
|
||||
backgroundColor: '#0b1a2e',
|
||||
|
||||
labelLanguage: 'ko',
|
||||
};
|
||||
|
||||
@ -122,7 +122,12 @@ export function OceanMapSettingsPanel({ value, onChange }: OceanMapSettingsPanel
|
||||
자동채우기
|
||||
</span>
|
||||
</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];
|
||||
if (!stop) return null;
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Depth opacity ─────────────────────────── */}
|
||||
|
||||
@ -29,6 +29,7 @@ import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
|
||||
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
|
||||
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||
import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel";
|
||||
import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement";
|
||||
import {
|
||||
buildLegacyHitMap,
|
||||
computeCountsByType,
|
||||
@ -62,6 +63,9 @@ export function DashboardPage() {
|
||||
const uid = user?.id ?? null;
|
||||
const isDevMode = user?.name?.includes('(DEV)') ?? false;
|
||||
|
||||
// ── Announcement popup ──
|
||||
const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid);
|
||||
|
||||
// ── Data fetching ──
|
||||
const { data: zones, error: zonesError } = useZones();
|
||||
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||
@ -435,6 +439,9 @@ export function DashboardPage() {
|
||||
) : selectedTarget ? (
|
||||
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} onOpenImageModal={handlePanelOpenImageModal} />
|
||||
) : null}
|
||||
{hasUnread && (
|
||||
<AnnouncementModal announcements={unreadAnnouncements} onConfirm={acknowledge} />
|
||||
)}
|
||||
{imageModal && (
|
||||
<ShipImageModal
|
||||
images={imageModal.images}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user