diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index 063b98e..e218375 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -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"; diff --git a/apps/web/src/app/styles/components/announcement.css b/apps/web/src/app/styles/components/announcement.css new file mode 100644 index 0000000..aae03e9 --- /dev/null +++ b/apps/web/src/app/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); +} diff --git a/apps/web/src/features/announcement/data/announcements.ts b/apps/web/src/features/announcement/data/announcements.ts new file mode 100644 index 0000000..5582f93 --- /dev/null +++ b/apps/web/src/features/announcement/data/announcements.ts @@ -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; diff --git a/apps/web/src/features/announcement/hooks/useAnnouncementPopup.ts b/apps/web/src/features/announcement/hooks/useAnnouncementPopup.ts new file mode 100644 index 0000000..34676c8 --- /dev/null +++ b/apps/web/src/features/announcement/hooks/useAnnouncementPopup.ts @@ -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( + 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 }; +} diff --git a/apps/web/src/features/announcement/index.ts b/apps/web/src/features/announcement/index.ts new file mode 100644 index 0000000..4ba6d2b --- /dev/null +++ b/apps/web/src/features/announcement/index.ts @@ -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'; diff --git a/apps/web/src/features/announcement/model/types.ts b/apps/web/src/features/announcement/model/types.ts new file mode 100644 index 0000000..4a1c774 --- /dev/null +++ b/apps/web/src/features/announcement/model/types.ts @@ -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[]; +} diff --git a/apps/web/src/features/announcement/ui/AnnouncementModal.tsx b/apps/web/src/features/announcement/ui/AnnouncementModal.tsx new file mode 100644 index 0000000..14ad048 --- /dev/null +++ b/apps/web/src/features/announcement/ui/AnnouncementModal.tsx @@ -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 ( +
+
e.stopPropagation()}> +
+ 업데이트 안내 + +
+ +
+ {announcements.map((announcement) => ( +
+
+ {announcement.title} + {announcement.date} +
+
    + {announcement.items.map((item, idx) => ( +
  • + {item.icon} +
    +
    {item.title}
    + {item.description && ( +
    {item.description}
    + )} +
    +
  • + ))} +
+
+ ))} +
+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/features/oceanMap/index.ts b/apps/web/src/features/oceanMap/index.ts index a326c1b..28fd507 100644 --- a/apps/web/src/features/oceanMap/index.ts +++ b/apps/web/src/features/oceanMap/index.ts @@ -1,5 +1,5 @@ export type { OceanMapSettings, OceanDepthStop, OceanLabelLanguage, OceanDepthLabelSize } from './model/types'; -export { DEFAULT_OCEAN_MAP_SETTINGS } from './model/types'; +export { DEFAULT_OCEAN_MAP_SETTINGS, OCEAN_PRESET_DEPTH_STOPS } from './model/types'; export { resolveOceanStyle } from './lib/resolveOceanStyle'; export { discoverOceanLayers } from './lib/oceanLayerIds'; export { useOceanMapSettings } from './hooks/useOceanMapSettings'; diff --git a/apps/web/src/features/oceanMap/model/types.ts b/apps/web/src/features/oceanMap/model/types.ts index 8db9897..e330468 100644 --- a/apps/web/src/features/oceanMap/model/types.ts +++ b/apps/web/src/features/oceanMap/model/types.ts @@ -38,40 +38,53 @@ export interface OceanMapSettings { labelLanguage: OceanLabelLanguage; } +/** + * Ocean 커스텀 수심 색상 프리셋 (12구간). + * 사용자가 "커스텀" 버튼을 누르면 이 값으로 초기화된다. + * Ocean 스타일에 어울리는 심해 → 천해 블루 그라데이션. + */ +export const OCEAN_PRESET_DEPTH_STOPS: OceanDepthStop[] = [ + { depth: -11000, color: '#0a0e2a' }, + { depth: -8000, color: '#0c1836' }, + { depth: -6000, color: '#0e2444' }, + { depth: -4000, color: '#103252' }, + { depth: -2000, color: '#134060' }, + { depth: -1000, color: '#175070' }, + { depth: -200, color: '#1c6480' }, + { depth: -100, color: '#217890' }, + { depth: -50, color: '#288da0' }, + { depth: -20, color: '#30a2b0' }, + { depth: -10, color: '#3ab5be' }, + { depth: 0, color: '#48c8cc' }, +]; + +/** + * 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', }; diff --git a/apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx b/apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx index ddf25a9..12fc3a0 100644 --- a/apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx +++ b/apps/web/src/features/oceanMap/ui/OceanMapSettingsPanel.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import type { OceanMapSettings, OceanLabelLanguage, OceanDepthLabelSize, OceanDepthStop } from '../model/types'; -import { DEFAULT_OCEAN_MAP_SETTINGS } from '../model/types'; +import { DEFAULT_OCEAN_MAP_SETTINGS, OCEAN_PRESET_DEPTH_STOPS } from '../model/types'; interface OceanMapSettingsPanelProps { value: OceanMapSettings; @@ -114,33 +114,57 @@ export function OceanMapSettingsPanel({ value, onChange }: OceanMapSettingsPanel
수심 구간 색상 - - 자동채우기 + + {value.depthStops.length > 0 && ( + + 자동채우기 + + )} + 0 ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`} + onClick={() => { + if (value.depthStops.length > 0) { + update('depthStops', []); + setAutoGradient(false); + } else { + update('depthStops', OCEAN_PRESET_DEPTH_STOPS); + } + }} + title={value.depthStops.length > 0 ? 'Ocean 스타일 네이티브 색상으로 복원' : '수심 구간별 색상을 직접 지정합니다'} + > + {value.depthStops.length > 0 ? '기본값' : '커스텀'} +
- {DISPLAY_DEPTH_INDICES.map((idx) => { - const stop = value.depthStops[idx]; - if (!stop) return null; - const isEdge = idx === 0 || idx === value.depthStops.length - 1; - const dimmed = autoGradient && !isEdge; - return ( -
- {depthLabel(stop.depth)} - updateDepthStop(idx, e.target.value)} - disabled={dimmed} - /> - {stop.color} -
- ); - })} + {value.depthStops.length === 0 ? ( +
+ 기본 스타일 사용 중 +
+ ) : ( + DISPLAY_DEPTH_INDICES.map((idx) => { + const stop = value.depthStops[idx]; + if (!stop) return null; + const isEdge = idx === 0 || idx === value.depthStops.length - 1; + const dimmed = autoGradient && !isEdge; + return ( +
+ {depthLabel(stop.depth)} + updateDepthStop(idx, e.target.value)} + disabled={dimmed} + /> + {stop.color} +
+ ); + }) + )}
{/* ── Depth opacity ─────────────────────────── */} diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 1847b13..086a3cd 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -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 ? ( setSelectedMmsi(null)} onOpenImageModal={handlePanelOpenImageModal} /> ) : null} + {hasUnread && ( + + )} {imageModal && (