From 4fa3bdb4a602e7b2628755c45807fa93c5136002 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 17 Apr 2026 21:42:01 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=88=20=ED=99=94=EB=A9=B4=20=EB=A6=AC?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8:=20=EA=B3=B5=EC=A7=80=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=84=B9=EC=85=98=ED=99=94=20+=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=9E=AC=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NoticeWidget을 4섹션 구조로 재구성 · 상단 2열 텍스트 리스트 (공지사항/업데이트, 5개/페이지, 슬라이드 전환, 이전/다음) · 이벤트/캐시샵은 2:1 이미지 카드 캐로셀, 제목 우측의 컴팩트 네비(◀ 1/N ▶) · D-day/시작 N일 전/상시판매 배지는 단색 배경 + 흰 글씨 medium · 넥슨 API 점검 시 섹션별 안내 표시 - 홈: 유틸리티를 상단으로 이동, 상·하단 UTILITIES/NOTICES 구분선, max-w-5xl 통일, pt-6 여백 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/NoticeWidget.jsx | 445 +++++++++++++---------- frontend/src/pages/Home.jsx | 15 +- 2 files changed, 273 insertions(+), 187 deletions(-) diff --git a/frontend/src/components/NoticeWidget.jsx b/frontend/src/components/NoticeWidget.jsx index 6484d76..ff6fbf0 100644 --- a/frontend/src/components/NoticeWidget.jsx +++ b/frontend/src/components/NoticeWidget.jsx @@ -1,235 +1,314 @@ import { useState } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQueries } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' import { api } from '../api/client' -const TABS = [ - { key: 'event', label: '이벤트', dataKey: 'event_notice', filterOngoing: true, dateStartKey: 'date_event_start', dateEndKey: 'date_event_end' }, - { key: 'cashshop', label: '캐시샵', dataKey: 'cashshop_notice', filterOngoing: true, dateStartKey: 'date_sale_start', dateEndKey: 'date_sale_end' }, - { key: 'update', label: '업데이트', dataKey: 'update_notice' }, - { key: 'notice', label: '공지', dataKey: 'notice' }, -] +const SECTIONS = { + notice: { label: '메이플스토리 공지사항', dataKey: 'notice', pageSize: 5, kind: 'text' }, + update: { label: '메이플스토리 업데이트', dataKey: 'update_notice', pageSize: 5, kind: 'text' }, + event: { + label: '진행 중인 이벤트', + dataKey: 'event_notice', + pageSize: 3, + kind: 'card', + dateStartKey: 'date_event_start', + dateEndKey: 'date_event_end', + filterOngoing: true, + }, + cashshop: { + label: '캐시샵 공지', + dataKey: 'cashshop_notice', + pageSize: 3, + kind: 'card', + dateStartKey: 'date_sale_start', + dateEndKey: 'date_sale_end', + filterOngoing: true, + }, +} -const DEFAULT_LIMIT = 6 - -function formatDate(iso) { +function fmtMD(iso) { if (!iso) return '' const d = new Date(iso) - const m = String(d.getMonth() + 1).padStart(2, '0') - const day = String(d.getDate()).padStart(2, '0') - return `${m}.${day}` + return `${d.getMonth() + 1}/${d.getDate()}` } - -function isOngoing(notice, tab) { - if (!tab.filterOngoing) return false - const endDate = notice[tab.dateEndKey] - // 종료일이 있으면 종료일 비교 - if (endDate) return new Date(endDate) > new Date() - // 종료일이 없으면 ongoing_flag로 판단 (캐시샵 상시판매 등) - if (notice.ongoing_flag !== undefined) { - return notice.ongoing_flag === 'true' || notice.ongoing_flag === true - } +function fmtYMD(iso) { + if (!iso) return '' + const d = new Date(iso) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} +function isRecent(iso, days = 3) { + if (!iso) return false + return (Date.now() - new Date(iso).getTime()) / 86400000 < days +} +function isOngoing(item, cfg) { + if (!cfg.filterOngoing) return true + const end = item[cfg.dateEndKey] + if (end) return new Date(end) > new Date() + if (item.ongoing_flag !== undefined) return item.ongoing_flag === 'true' || item.ongoing_flag === true return false } - -function splitTitle(title) { - // "3월 23일 캐시아이템 업데이트 - 메이플스토리 & 진" → ["3월 23일 캐시아이템 업데이트", "메이플스토리 & 진"] - const idx = title.indexOf(' - ') - if (idx === -1) return { prefix: null, main: title } - return { - prefix: title.slice(0, idx), - main: title.slice(idx + 3), +function dayBadge(item, cfg) { + const now = Date.now() + const start = item[cfg.dateStartKey] ? new Date(item[cfg.dateStartKey]).getTime() : null + const end = item[cfg.dateEndKey] ? new Date(item[cfg.dateEndKey]).getTime() : null + if (start && start > now) { + const d = Math.ceil((start - now) / 86400000) + return { label: `시작 ${d}일 전`, tone: 'emerald' } } + if (end) { + const d = Math.ceil((end - now) / 86400000) + if (d <= 0) return null + return { label: `D-${d}`, tone: 'amber' } + } + if (item.ongoing_flag === 'true' || item.ongoing_flag === true) { + return { label: '상시판매', tone: 'gray' } + } + return null } -function NoticeCard({ notice, tab }) { - const startDate = tab.dateStartKey ? notice[tab.dateStartKey] : null - const endDate = tab.dateEndKey ? notice[tab.dateEndKey] : null - const hasDateRange = startDate || endDate +/* ─── Text List Section ─────────────────────────────────────── */ - const dateText = hasDateRange - ? `${formatDate(startDate || notice.date)}${endDate ? ` ~ ${formatDate(endDate)}` : ''}` - : (tab.key === 'cashshop' && isOngoing(notice, tab) ? '상시판매' : formatDate(notice.date)) +function TextListSection({ cfg, items, isMaintenance, isLoading }) { + const [page, setPage] = useState(0) + const pages = Math.max(1, Math.ceil(items.length / cfg.pageSize)) + const clamped = Math.min(page, pages - 1) + const slice = items.slice(clamped * cfg.pageSize, (clamped + 1) * cfg.pageSize) - const { prefix, main } = splitTitle(notice.title) + return ( +
+
+

{cfg.label}

+
+
+ {isLoading ? ( +
불러오는 중...
+ ) : isMaintenance ? ( +
+
넥슨 Open API 점검중
+
+ ) : slice.length === 0 ? ( +
등록된 항목이 없습니다
+ ) : ( + + + {slice.map((it) => ( +
  • + + {isRecent(it.date) && ( + N + )} + + {it.title} + + {fmtYMD(it.date)} + +
  • + ))} +
    +
    + )} +
    + {pages > 1 && ( +
    + +
    + {Array.from({ length: pages }).map((_, i) => ( +
    + +
    + )} +
    + ) +} + +/* ─── Carousel Section (image cards) ────────────────────────── */ + +function CardItem({ item, cfg }) { + const badge = dayBadge(item, cfg) + const start = item[cfg.dateStartKey] + const end = item[cfg.dateEndKey] + const dateText = (item.ongoing_flag === 'true' || item.ongoing_flag === true) + ? '상시판매' + : start || end + ? `${fmtMD(start || item.date)} ~ ${fmtMD(end || item.date)}` + : fmtYMD(item.date) + const toneCls = { + emerald: 'bg-emerald-600 text-white', + amber: 'bg-amber-600 text-white', + gray: 'bg-gray-700 text-white', + }[badge?.tone] return ( - {notice.thumbnail_url && ( -
    +
    + {item.thumbnail_url ? ( + ) : ( +
    📢
    + )} + {badge && ( + + {badge.label} + + )} +
    +
    +
    + {item.title}
    - )} -
    -
    - {prefix && ( -

    {prefix}

    - )} -

    - {main} -

    -
    -
    - 📅 - {dateText} -
    +
    {dateText}
    ) } -export default function NoticeWidget() { - const [activeTab, setActiveTab] = useState('event') - const [expanded, setExpanded] = useState(false) - const tab = TABS.find((t) => t.key === activeTab) - - const { data, isLoading, error } = useQuery({ - queryKey: ['notices', activeTab], - queryFn: () => api(`/api/notices?type=${activeTab}`), - staleTime: 5 * 60 * 1000, - retry: (failureCount, err) => (err?.maintenance ? false : failureCount < 1), - }) - const isMaintenance = !!error?.maintenance - - const list = data?.[tab.dataKey] || [] - const allItems = tab.filterOngoing - ? list.filter((n) => isOngoing(n, tab)) - : list - const initialItems = allItems.slice(0, DEFAULT_LIMIT) - const extraItems = allItems.slice(DEFAULT_LIMIT) - const hasMore = extraItems.length > 0 - - const handleTabChange = (key) => { - setActiveTab(key) - setExpanded(false) - } +function CarouselSection({ cfg, items, isMaintenance, isLoading }) { + const [page, setPage] = useState(0) + const pages = Math.max(1, Math.ceil(items.length / cfg.pageSize)) + const clamped = Math.min(page, pages - 1) + const slice = items.slice(clamped * cfg.pageSize, (clamped + 1) * cfg.pageSize) return ( -
    - {/* 헤더 + 탭 */} -
    -
    - 📢 -

    메이플 공지

    - {tab.filterOngoing && allItems.length > 0 && ( - 진행중 {allItems.length}건 - )} -
    -
    - {TABS.map((t) => ( +
    +
    +

    {cfg.label}

    + {pages > 1 && ( +
    - ))} -
    + + {clamped + 1} + / + {pages} + + +
    + )}
    - - {/* 목록 */} -
    +
    {isLoading ? ( -
    - {Array.from({ length: 6 }).map((_, i) => ( -
    +
    + {Array.from({ length: cfg.pageSize }).map((_, i) => ( +
    ))}
    ) : isMaintenance ? ( -
    +
    넥슨 Open API 점검중
    -
    점검이 끝나면 다시 표시됩니다
    - ) : initialItems.length === 0 ? ( - + ) : slice.length === 0 ? ( +
    + {cfg.filterOngoing ? `진행중인 ${cfg.label}이 없습니다` : `등록된 ${cfg.label}이 없습니다`} +
    + ) : ( + - {tab.filterOngoing ? `진행중인 ${tab.label}이 없습니다` : `등록된 ${tab.label}이 없습니다`} + {slice.map((it) => )} - ) : ( - <> - - - {initialItems.map((notice) => ( - - ))} - - - - {/* 펼쳐지는 영역 */} - - {hasMore && expanded && ( - -
    - {extraItems.map((notice) => ( - - ))} -
    -
    - )} -
    - - )} - - {/* 더보기 / 접기 */} - {hasMore && ( - )}
    ) } + +/* ─── Root ──────────────────────────────────────────────────── */ + +export default function NoticeWidget() { + const queries = useQueries({ + queries: Object.keys(SECTIONS).map((key) => ({ + queryKey: ['notices', key], + queryFn: () => api(`/api/notices?type=${key}`), + staleTime: 5 * 60 * 1000, + retry: (n, err) => (err?.maintenance ? false : n < 1), + })), + }) + + const byKey = Object.keys(SECTIONS).reduce((acc, key, i) => { + const q = queries[i] + const cfg = SECTIONS[key] + const list = q.data?.[cfg.dataKey] || [] + const items = cfg.filterOngoing ? list.filter((n) => isOngoing(n, cfg)) : list + acc[key] = { items, isLoading: q.isLoading, isMaintenance: !!q.error?.maintenance } + return acc + }, {}) + + return ( +
    +
    + + +
    + + +
    + ) +} diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 60278db..cf52ad3 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -10,10 +10,7 @@ export default function Home() { }) return ( -
    - {/* 메이플 공지 */} - - +
    {/* 구분선 */}
    @@ -57,6 +54,16 @@ export default function Home() {
    )} + + {/* 구분선 */} +
    +
    + Notices +
    +
    + + {/* 메이플 공지 */} +
    ) }