From 4be648c21c6f8f54fb823236513e673a28cfd1cb Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 19 Apr 2026 11:47:40 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=205?= =?UTF-8?q?=EB=8B=A8=EA=B3=84:=20NoticeWidget.jsx=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(431=20=E2=86=92=204=EA=B0=9C?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - components/pc/NoticeWidget/ - index.jsx (38): 루트 + useQueries - config.js (62): SECTIONS 정의 + 날짜/배지 헬퍼 - TextListSection.jsx (140): memo - CarouselSection.jsx (165): CardItem + memo Home.jsx의 import 'components/pc/NoticeWidget'는 자동으로 index.jsx로 resolve Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/pc/NoticeWidget.jsx | 431 ------------------ .../pc/NoticeWidget/CarouselSection.jsx | 165 +++++++ .../pc/NoticeWidget/TextListSection.jsx | 140 ++++++ .../src/components/pc/NoticeWidget/config.js | 62 +++ .../src/components/pc/NoticeWidget/index.jsx | 38 ++ 5 files changed, 405 insertions(+), 431 deletions(-) delete mode 100644 frontend/src/components/pc/NoticeWidget.jsx create mode 100644 frontend/src/components/pc/NoticeWidget/CarouselSection.jsx create mode 100644 frontend/src/components/pc/NoticeWidget/TextListSection.jsx create mode 100644 frontend/src/components/pc/NoticeWidget/config.js create mode 100644 frontend/src/components/pc/NoticeWidget/index.jsx diff --git a/frontend/src/components/pc/NoticeWidget.jsx b/frontend/src/components/pc/NoticeWidget.jsx deleted file mode 100644 index 00a80a9..0000000 --- a/frontend/src/components/pc/NoticeWidget.jsx +++ /dev/null @@ -1,431 +0,0 @@ -import { useState } from 'react' -import { useQueries } from '@tanstack/react-query' -import { motion, AnimatePresence } from 'framer-motion' -import { api } from '../../api/client' - -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, - }, -} - -function fmtMD(iso) { - if (!iso) return '' - const d = new Date(iso) - return `${d.getMonth() + 1}/${d.getDate()}` -} -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 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}일 전`, tone: 'amber' } - } - if (item.ongoing_flag === 'true' || item.ongoing_flag === true) { - return { label: '상시판매', tone: 'gray' } - } - return null -} - -/* ─── Text List Section ─────────────────────────────────────── */ - -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) - - 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 startMD = fmtMD(start || item.date) - const endMD = fmtMD(end || item.date) - const dateText = (item.ongoing_flag === 'true' || item.ongoing_flag === true) - ? '상시판매' - : start || end - ? (startMD === endMD ? startMD : `${startMD} ~ ${endMD}`) - : fmtYMD(item.date) - const badgeBg = { - emerald: 'var(--badge-emerald-bg)', - amber: 'var(--badge-amber-bg)', - gray: 'var(--badge-gray-bg)', - }[badge?.tone] - - return ( - -
    - {item.thumbnail_url ? ( - - ) : ( -
    - 📢 -
    - )} - {badge && ( - - {badge.label} - - )} -
    -
    -
    - {item.title} -
    -
    - {dateText} -
    -
    -
    - ) -} - -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) - - const navBtn = "w-7 h-7 rounded-md border flex items-center justify-center border-[var(--btn-border)] bg-[var(--btn-bg)] hover:bg-[var(--btn-bg-hover)] hover:border-[var(--btn-border-hover)] disabled:opacity-30 disabled:hover:bg-[var(--btn-bg)] disabled:hover:border-[var(--btn-border)]" - - return ( -
    -
    -

    - {cfg.label} -

    - {pages > 1 && ( -
    - - - {clamped + 1} - / - {pages} - - -
    - )} -
    -
    - {isLoading ? ( -
    - {Array.from({ length: cfg.pageSize }).map((_, i) => ( -
    - ))} -
    - ) : isMaintenance ? ( -
    -
    - 넥슨 Open API 점검중 -
    -
    - ) : slice.length === 0 ? ( -
    - {cfg.filterOngoing ? `진행중인 ${cfg.label}이 없습니다` : `등록된 ${cfg.label}이 없습니다`} -
    - ) : ( - - - {slice.map((it) => )} - - - )} -
    -
    - ) -} - -/* ─── 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/components/pc/NoticeWidget/CarouselSection.jsx b/frontend/src/components/pc/NoticeWidget/CarouselSection.jsx new file mode 100644 index 0000000..c8b2808 --- /dev/null +++ b/frontend/src/components/pc/NoticeWidget/CarouselSection.jsx @@ -0,0 +1,165 @@ +import { memo, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { fmtMD, fmtYMD, dayBadge } from './config' + +function CardItem({ item, cfg }) { + const badge = dayBadge(item, cfg) + const start = item[cfg.dateStartKey] + const end = item[cfg.dateEndKey] + const startMD = fmtMD(start || item.date) + const endMD = fmtMD(end || item.date) + const dateText = (item.ongoing_flag === 'true' || item.ongoing_flag === true) + ? '상시판매' + : start || end + ? (startMD === endMD ? startMD : `${startMD} ~ ${endMD}`) + : fmtYMD(item.date) + const badgeBg = { + emerald: 'var(--badge-emerald-bg)', + amber: 'var(--badge-amber-bg)', + gray: 'var(--badge-gray-bg)', + }[badge?.tone] + + return ( + +
    + {item.thumbnail_url ? ( + + ) : ( +
    + 📢 +
    + )} + {badge && ( + + {badge.label} + + )} +
    +
    +
    + {item.title} +
    +
    + {dateText} +
    +
    +
    + ) +} + +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) + + const navBtn = "w-7 h-7 rounded-md border flex items-center justify-center border-[var(--btn-border)] bg-[var(--btn-bg)] hover:bg-[var(--btn-bg-hover)] hover:border-[var(--btn-border-hover)] disabled:opacity-30 disabled:hover:bg-[var(--btn-bg)] disabled:hover:border-[var(--btn-border)]" + + return ( +
    +
    +

    + {cfg.label} +

    + {pages > 1 && ( +
    + + + {clamped + 1} + / + {pages} + + +
    + )} +
    +
    + {isLoading ? ( +
    + {Array.from({ length: cfg.pageSize }).map((_, i) => ( +
    + ))} +
    + ) : isMaintenance ? ( +
    +
    + 넥슨 Open API 점검중 +
    +
    + ) : slice.length === 0 ? ( +
    + {cfg.filterOngoing ? `진행중인 ${cfg.label}이 없습니다` : `등록된 ${cfg.label}이 없습니다`} +
    + ) : ( + + + {slice.map((it) => )} + + + )} +
    +
    + ) +} + +export default memo(CarouselSection) diff --git a/frontend/src/components/pc/NoticeWidget/TextListSection.jsx b/frontend/src/components/pc/NoticeWidget/TextListSection.jsx new file mode 100644 index 0000000..6d7be24 --- /dev/null +++ b/frontend/src/components/pc/NoticeWidget/TextListSection.jsx @@ -0,0 +1,140 @@ +import { memo, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { fmtYMD, isRecent } from './config' + +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) + + 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) => ( +
    + +
    + )} +
    + ) +} + +export default memo(TextListSection) diff --git a/frontend/src/components/pc/NoticeWidget/config.js b/frontend/src/components/pc/NoticeWidget/config.js new file mode 100644 index 0000000..1170638 --- /dev/null +++ b/frontend/src/components/pc/NoticeWidget/config.js @@ -0,0 +1,62 @@ +export 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, + }, +} + +export function fmtMD(iso) { + if (!iso) return '' + const d = new Date(iso) + return `${d.getMonth() + 1}/${d.getDate()}` +} +export 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')}` +} +export function isRecent(iso, days = 3) { + if (!iso) return false + return (Date.now() - new Date(iso).getTime()) / 86400000 < days +} +export 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 +} +export 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}일 전`, tone: 'amber' } + } + if (item.ongoing_flag === 'true' || item.ongoing_flag === true) { + return { label: '상시판매', tone: 'gray' } + } + return null +} diff --git a/frontend/src/components/pc/NoticeWidget/index.jsx b/frontend/src/components/pc/NoticeWidget/index.jsx new file mode 100644 index 0000000..9eef838 --- /dev/null +++ b/frontend/src/components/pc/NoticeWidget/index.jsx @@ -0,0 +1,38 @@ +import { useQueries } from '@tanstack/react-query' +import { api } from '../../../api/client' +import { SECTIONS, isOngoing } from './config' +import TextListSection from './TextListSection' +import CarouselSection from './CarouselSection' + +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 ( +
    +
    + + +
    +
    + +
    + +
    + ) +}