홈 페이지 공지 위젯 + 푸터 추가

- 넥슨 공지 위젯 (이벤트/캐시샵/업데이트/공지 탭)
- 이벤트/캐시샵은 진행중인 항목 모두, 그 외는 최근 6개
- 더보기/접기 grid-template-rows 애니메이션
- 캐시샵 ongoing_flag 대신 종료일 비교 (누락 항목 수정)
- 제목/부제목 분리, 달력 이모지로 기간 표시
- 공통 푸터 (저작권, 데이터 출처)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-13 16:48:05 +09:00
parent 3df639c815
commit 793903668c
6 changed files with 274 additions and 16 deletions

31
backend/routes/notices.js Normal file
View file

@ -0,0 +1,31 @@
import { Router } from 'express';
import axios from 'axios';
const router = Router();
const NEXON_API_BASE = 'https://open.api.nexon.com';
const ENDPOINT_MAP = {
event: '/maplestory/v1/notice-event',
update: '/maplestory/v1/notice-update',
notice: '/maplestory/v1/notice',
cashshop: '/maplestory/v1/notice-cashshop',
};
router.get('/', async (req, res) => {
const type = req.query.type || 'event';
const endpoint = ENDPOINT_MAP[type];
if (!endpoint) return res.status(400).json({ error: '잘못된 type입니다' });
try {
const { data } = await axios.get(`${NEXON_API_BASE}${endpoint}`, {
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
});
res.json(data);
} catch (err) {
console.error(`공지 조회 오류 (${type}):`, err.response?.data || err.message);
res.status(500).json({ error: '공지 조회 실패' });
}
});
export default router;

View file

@ -2,6 +2,7 @@ import express from 'express';
import cors from 'cors';
import adminRoutes from './routes/admin.js';
import menuRoutes from './routes/menus.js';
import noticeRoutes from './routes/notices.js';
import { sequelize } from './lib/db.js';
import './models/index.js';
@ -17,6 +18,7 @@ app.use(cors({
app.use(express.json());
app.use('/api/menus', menuRoutes);
app.use('/api/notices', noticeRoutes);
app.use('/api/admin', adminRoutes);
app.get('/api/health', (_req, res) => {

View file

@ -0,0 +1,25 @@
export default function Footer() {
return (
<footer className="border-t border-white/5 mt-16">
<div className="mx-auto max-w-5xl px-6 py-8 space-y-4">
<div className="flex items-center gap-2.5">
<img src="/favicon.ico" alt="" className="w-6 h-6" />
<span className="font-bold text-sm">메이플스토리 유틸리티</span>
</div>
<div className="grid gap-2 sm:grid-cols-2 text-xs text-gray-500">
<div>
<p> 사이트는 NEXON Korea의 공식 사이트가 아닙니다.</p>
<p>MapleStory의 모든 저작권은 NEXON Korea에 있습니다.</p>
</div>
<div className="sm:text-right">
<p>
데이터 출처: <a href="https://openapi.nexon.com" target="_blank" rel="noopener noreferrer" className="text-emerald-400/80 hover:text-emerald-300 transition">NEXON Open API</a>
</p>
<p>© {new Date().getFullYear()} caadiq</p>
</div>
</div>
</div>
</footer>
)
}

View file

@ -1,8 +1,9 @@
import { Outlet, Link } from 'react-router-dom'
import Footer from './Footer'
export default function Layout() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-950 to-slate-900 text-white">
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-950 to-slate-900 text-white flex flex-col">
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<Link to="/" className="group flex items-center gap-2.5">
@ -13,9 +14,10 @@ export default function Layout() {
</Link>
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-10">
<main className="flex-1 mx-auto w-full max-w-5xl px-6 py-10">
<Outlet />
</main>
<Footer />
</div>
)
}

View file

@ -0,0 +1,201 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
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 DEFAULT_LIMIT = 6
function formatDate(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}`
}
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
}
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 NoticeCard({ notice, tab }) {
const startDate = tab.dateStartKey ? notice[tab.dateStartKey] : null
const endDate = tab.dateEndKey ? notice[tab.dateEndKey] : null
const hasDateRange = startDate || endDate
const dateText = hasDateRange
? `${formatDate(startDate || notice.date)}${endDate ? ` ~ ${formatDate(endDate)}` : ''}`
: (tab.key === 'cashshop' && isOngoing(notice, tab) ? '상시판매' : formatDate(notice.date))
const { prefix, main } = splitTitle(notice.title)
return (
<a
href={notice.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"
>
{notice.thumbnail_url && (
<div className="shrink-0 w-20 h-20 rounded-md bg-gray-900 overflow-hidden">
<img
src={notice.thumbnail_url}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition duration-500"
loading="lazy"
/>
</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>
)}
<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>
</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 } = useQuery({
queryKey: ['notices', activeTab],
queryFn: () => api(`/api/notices?type=${activeTab}`),
staleTime: 5 * 60 * 1000,
})
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)
}
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>
)}
</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">
{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>
) : initialItems.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
{tab.filterOngoing ? `진행중인 ${tab.label}이 없습니다` : `등록된 ${tab.label}이 없습니다`}
</div>
) : (
<>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{initialItems.map((notice) => (
<NoticeCard key={notice.notice_id} notice={notice} tab={tab} />
))}
</div>
{/* 펼쳐지는 영역 - grid-template-rows 트릭으로 부드럽게 애니메이션 */}
{hasMore && (
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
>
<div className="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>
</div>
</div>
)}
</>
)}
{/* 더보기 / 접기 */}
{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-300 ${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>
)
}

View file

@ -1,6 +1,7 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import NoticeWidget from '../components/NoticeWidget'
export default function Home() {
const { data: menus = [], isLoading: loading } = useQuery({
@ -9,20 +10,16 @@ export default function Home() {
})
return (
<div className="space-y-12">
{/* Hero */}
<section className="text-center pt-12 pb-4">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-xs text-emerald-300 mb-6">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
MapleStory Utility
</div>
<h1 className="text-4xl sm:text-5xl font-bold tracking-tight bg-gradient-to-br from-white via-white to-gray-500 bg-clip-text text-transparent">
메이플스토리 유틸리티
</h1>
<p className="text-gray-400 mt-4 text-base">
메이플스토리 플레이를 위한 유용한 도구 모음
</p>
</section>
<div className="space-y-10">
{/* 메이플 공지 */}
<NoticeWidget />
{/* 구분선 */}
<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">Utilities</span>
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-white/10 to-transparent" />
</div>
{/* 메뉴 그리드 */}
<section>