홈 화면 리디자인: 공지 위젯 섹션화 + 레이아웃 재배치
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
2d43b78ce4
commit
4fa3bdb4a6
2 changed files with 273 additions and 187 deletions
|
|
@ -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 (
|
||||
<section className="rounded-2xl border border-white/5 bg-gray-900/50 overflow-hidden flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-white/5">
|
||||
<h3 className="text-sm font-bold text-gray-200">{cfg.label}</h3>
|
||||
</div>
|
||||
<div className="relative overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-gray-500">불러오는 중...</div>
|
||||
) : isMaintenance ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="text-sm text-amber-300 font-medium">넥슨 Open API 점검중</div>
|
||||
</div>
|
||||
) : slice.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-500">등록된 항목이 없습니다</div>
|
||||
) : (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.ul
|
||||
key={`page-${clamped}`}
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -24 }}
|
||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="divide-y divide-white/[0.04]"
|
||||
>
|
||||
{slice.map((it) => (
|
||||
<li key={it.notice_id} className="flex items-center gap-2">
|
||||
<a
|
||||
href={it.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 min-w-0 flex items-center gap-2 px-3.5 py-2 hover:bg-white/[0.03] transition"
|
||||
>
|
||||
{isRecent(it.date) && (
|
||||
<span className="shrink-0 inline-flex items-center justify-center w-4 h-4 rounded-full bg-emerald-500 text-[9px] font-bold text-gray-950">N</span>
|
||||
)}
|
||||
<span className="flex-1 min-w-0 text-[13px] text-gray-300 truncate hover:text-emerald-300 transition">
|
||||
{it.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-gray-500 tabular-nums">{fmtYMD(it.date)}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-white/5 px-4 py-3 text-sm text-gray-400">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={clamped === 0}
|
||||
className="inline-flex items-center gap-1.5 hover:text-gray-100 disabled:opacity-30 disabled:hover:text-gray-400 transition"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7.5 3L4.5 6L7.5 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
이전
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{Array.from({ length: pages }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setPage(i)}
|
||||
aria-label={`${i + 1}페이지`}
|
||||
className={`w-2 h-2 rounded-full transition ${i === clamped ? 'bg-emerald-400' : 'bg-gray-600 hover:bg-gray-500'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}
|
||||
disabled={clamped >= pages - 1}
|
||||
className="inline-flex items-center gap-1.5 hover:text-gray-100 disabled:opacity-30 disabled:hover:text-gray-400 transition"
|
||||
>
|
||||
다음
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── 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 (
|
||||
<a
|
||||
href={notice.url}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex gap-3 rounded-lg border border-white/5 bg-gray-950/40 p-2 hover:border-white/15 hover:bg-gray-950/70 transition"
|
||||
className="group relative block rounded-xl overflow-hidden bg-gray-900 border border-white/5 hover:border-white/15 transition"
|
||||
>
|
||||
{notice.thumbnail_url && (
|
||||
<div className="shrink-0 w-20 h-20 rounded-md bg-gray-900 overflow-hidden">
|
||||
<div className="aspect-[2/1] bg-gray-950 overflow-hidden">
|
||||
{item.thumbnail_url ? (
|
||||
<img
|
||||
src={notice.thumbnail_url}
|
||||
src={item.thumbnail_url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition duration-500"
|
||||
className="w-full h-full object-cover group-hover:scale-[1.03] transition duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-4xl text-gray-700">📢</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between py-1 gap-1.5">
|
||||
<div className="min-w-0 space-y-1">
|
||||
{prefix && (
|
||||
<p className="text-xs text-gray-500 truncate leading-tight">{prefix}</p>
|
||||
{badge && (
|
||||
<span className={`absolute top-2 right-2 px-2 py-0.5 rounded-full text-[11px] font-medium ${toneCls}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-sm font-medium text-gray-200 group-hover:text-emerald-300 transition line-clamp-2 leading-snug">
|
||||
{main}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<span>📅</span>
|
||||
<span>{dateText}</span>
|
||||
<div className="p-3 space-y-1">
|
||||
<div className="text-sm font-medium text-gray-200 line-clamp-1 group-hover:text-emerald-300 transition">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 tabular-nums">{dateText}</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 overflow-hidden">
|
||||
{/* 헤더 + 탭 */}
|
||||
<div className="flex items-center justify-between border-b border-white/5 px-5 py-3 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">📢</span>
|
||||
<h2 className="font-semibold">메이플 공지</h2>
|
||||
{tab.filterOngoing && allItems.length > 0 && (
|
||||
<span className="text-xs text-gray-500">진행중 {allItems.length}건</span>
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-bold text-gray-200">{cfg.label}</h3>
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center gap-3 text-sm text-gray-400">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={clamped === 0}
|
||||
className="w-7 h-7 rounded-md border border-white/10 bg-gray-900/60 hover:bg-gray-800 hover:border-white/20 disabled:opacity-30 disabled:hover:bg-gray-900/60 disabled:hover:border-white/10 transition flex items-center justify-center"
|
||||
aria-label="이전"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7.5 3L4.5 6L7.5 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
<span className="tabular-nums text-gray-500 min-w-[48px] text-center">
|
||||
<span className="text-gray-200">{clamped + 1}</span>
|
||||
<span className="mx-1 text-gray-600">/</span>
|
||||
{pages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}
|
||||
disabled={clamped >= pages - 1}
|
||||
className="w-7 h-7 rounded-md border border-white/10 bg-gray-900/60 hover:bg-gray-800 hover:border-white/20 disabled:opacity-30 disabled:hover:bg-gray-900/60 disabled:hover:border-white/10 transition flex items-center justify-center"
|
||||
aria-label="다음"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => handleTabChange(t.key)}
|
||||
className={`text-xs px-2.5 py-1 rounded-md transition ${
|
||||
activeTab === t.key
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-gray-500 hover:text-gray-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="p-3">
|
||||
<div className="relative overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-24 rounded-lg bg-white/[0.02] animate-pulse" />
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: cfg.pageSize }).map((_, i) => (
|
||||
<div key={i} className="aspect-[2/1] rounded-xl bg-white/[0.02] animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : isMaintenance ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="py-10 rounded-xl border border-white/5 bg-gray-900/50 text-center">
|
||||
<div className="text-sm text-amber-300 font-medium">넥슨 Open API 점검중</div>
|
||||
<div className="text-xs text-gray-500 mt-1">점검이 끝나면 다시 표시됩니다</div>
|
||||
</div>
|
||||
) : initialItems.length === 0 ? (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`empty-${activeTab}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="py-12 text-center text-sm text-gray-500"
|
||||
>
|
||||
{tab.filterOngoing ? `진행중인 ${tab.label}이 없습니다` : `등록된 ${tab.label}이 없습니다`}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
) : slice.length === 0 ? (
|
||||
<div className="py-10 rounded-xl border border-dashed border-white/10 text-center text-sm text-gray-500">
|
||||
{cfg.filterOngoing ? `진행중인 ${cfg.label}이 없습니다` : `등록된 ${cfg.label}이 없습니다`}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`grid-${activeTab}`}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="grid gap-2 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
||||
key={`cpage-${clamped}`}
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -30 }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{initialItems.map((notice) => (
|
||||
<NoticeCard key={notice.notice_id} notice={notice} tab={tab} />
|
||||
))}
|
||||
{slice.map((it) => <CardItem key={it.notice_id} item={it} cfg={cfg} />)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 펼쳐지는 영역 */}
|
||||
<AnimatePresence initial={false}>
|
||||
{hasMore && expanded && (
|
||||
<motion.div
|
||||
key="extra"
|
||||
initial={{ height: 0, opacity: 0, y: -8 }}
|
||||
animate={{ height: 'auto', opacity: 1, y: 0 }}
|
||||
exit={{ height: 0, opacity: 0, y: -8 }}
|
||||
transition={{
|
||||
height: { duration: 0.45, ease: [0.22, 1, 0.36, 1] },
|
||||
opacity: { duration: 0.35, ease: [0.22, 1, 0.36, 1] },
|
||||
y: { duration: 0.45, ease: [0.22, 1, 0.36, 1] },
|
||||
}}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 pt-2">
|
||||
{extraItems.map((notice) => (
|
||||
<NoticeCard key={notice.notice_id} notice={notice} tab={tab} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 더보기 / 접기 */}
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-3 w-full rounded-lg border border-white/5 bg-gray-950/30 hover:bg-gray-950/60 hover:border-white/10 py-2 text-xs text-gray-400 hover:text-gray-200 transition flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<span>{expanded ? '접기' : `더보기 (${extraItems.length}건)`}</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className={`transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${expanded ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── 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 (
|
||||
<section className="space-y-6">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<TextListSection cfg={SECTIONS.notice} {...byKey.notice} />
|
||||
<TextListSection cfg={SECTIONS.update} {...byKey.update} />
|
||||
</div>
|
||||
<CarouselSection cfg={SECTIONS.event} {...byKey.event} />
|
||||
<CarouselSection cfg={SECTIONS.cashshop} {...byKey.cashshop} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ export default function Home() {
|
|||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* 메이플 공지 */}
|
||||
<NoticeWidget />
|
||||
|
||||
<div className="space-y-10 max-w-5xl mx-auto pt-6">
|
||||
{/* 구분선 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
|
|
@ -57,6 +54,16 @@ export default function Home() {
|
|||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest">Notices</span>
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* 메이플 공지 */}
|
||||
<NoticeWidget />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue