From c9a130ea65d1ec7901ad932bc19883616c604157 Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 15 Apr 2026 12:07:07 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=AC=EB=B3=BC=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EA=B8=B0=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=B4=88=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /symbol 경로에 Symbol 페이지 추가 (풀스크린 레이아웃) - 아케인/어센틱/그랜드 어센틱 탭 (DB에서 대표 심볼 아이콘 가져옴) - 캐릭터 닉네임 검색 → /api/character/search 연동 및 여러 캐릭터 추가 가능 - 캐릭터 카드: 큰 이미지 + 닉네임 + 레벨/직업 (좌우 스크롤) - 카드 우상단 삭제 버튼 - 캐릭터 목록 + 선택 상태 localStorage 영속화 - 심볼 카드 그리드: 아이콘, 레벨, 성장치 진행바, 일퀘/주퀘 획득 입력, 남은 심볼/필요 메소/체납 메소/남은 일수/예상 완료일 (목업) - 하단 요약 카드: 만렙 완료 예상일 + 누적 체납 메소 + 누적 필요 메소 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/features/symbol/Symbol.jsx | 324 ++++++++++++++++++++++++ frontend/src/features/symbol/data.js | 30 +++ 2 files changed, 354 insertions(+) create mode 100644 frontend/src/features/symbol/Symbol.jsx create mode 100644 frontend/src/features/symbol/data.js diff --git a/frontend/src/features/symbol/Symbol.jsx b/frontend/src/features/symbol/Symbol.jsx new file mode 100644 index 0000000..f9000e1 --- /dev/null +++ b/frontend/src/features/symbol/Symbol.jsx @@ -0,0 +1,324 @@ +import { useState, useEffect } from 'react' +import { useQuery, useMutation } from '@tanstack/react-query' +import { api } from '../../api/client' +import { useLayout } from '../../components/Layout' +import { SYMBOL_TABS, SYMBOLS } from './data' + +function CharacterCard({ char, active, onSelect, onRemove }) { + return ( +
{ + if (e.target.closest('button')) return + onSelect() + }} + className={`group relative shrink-0 w-36 rounded-xl border cursor-pointer select-none transition ${ + active + ? 'border-emerald-500/40 bg-emerald-500/[0.08]' + : 'border-white/5 hover:border-white/15 bg-gray-950/40 hover:bg-gray-950/60' + }`} + > + {/* 삭제 (우상단) */} + + + {/* 내용 */} +
+
+ {char.character_image ? ( + + ) : ( + ? + )} +
+
+ {char.character_name} +
+
+ Lv.{char.character_level} · {char.job_name} +
+
+
+ ) +} + +function TabImage({ name }) { + const { data } = useQuery({ + queryKey: ['image', name], + queryFn: () => api('/api/images/' + encodeURIComponent(name)).catch(() => null), + staleTime: Infinity, + }) + if (!data?.url) return
+ return +} + +function SymbolCard({ symbol, equipped }) { + // 임시 목업 데이터 + const level = equipped ? 7 : 0 + const maxLevel = 20 + const growth = equipped ? 120 : 0 + const requireGrowth = 60 + const remainingSymbols = 540 + const remainingMeso = 128_000_000 + const daysLeft = equipped ? 84 : '-' + const completeDate = equipped ? '2026년 07월 09일 (목)' : '미장착' + + return ( +
+
+
+ {symbol.name} +
+
+
{symbol.name}
+
+ Lv.{level} + / {maxLevel} +
+
+
+ + {/* 진행도 바 */} +
+
+ 성장치 {growth} / {requireGrowth} + {requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}% +
+
+
+
+
+ + {/* 획득량 입력 */} +
+
+ + +
+
+ + +
+
+ + {/* 정보 */} +
+
+ 남은 심볼 + {remainingSymbols.toLocaleString()} +
+
+ 필요 메소 + {remainingMeso.toLocaleString()} +
+
+ 체납 메소 + {(equipped ? 18_000_000 : 0).toLocaleString()} +
+
+ 남은 일수 + {typeof daysLeft === 'number' ? `${daysLeft}일` : daysLeft} +
+
+ 예상 완료일 + + {completeDate} + +
+
+
+ ) +} + +export default function Symbol() { + const { setFullscreen } = useLayout() + useEffect(() => { + setFullscreen(true) + return () => setFullscreen(false) + }, [setFullscreen]) + + const STORAGE_KEY = 'maple-symbol' + const [tab, setTab] = useState('arcane') + const [characters, setCharacters] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved) return JSON.parse(saved).characters || [] + } catch { /* ignore */ } + return [] + }) + const [selectedCharId, setSelectedCharId] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved) return JSON.parse(saved).selectedCharId ?? null + } catch { /* ignore */ } + return null + }) + const [addName, setAddName] = useState('') + const [addError, setAddError] = useState('') + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ characters, selectedCharId })) + }, [characters, selectedCharId]) + const symbols = SYMBOLS[tab] + const tabInfo = SYMBOL_TABS.find((t) => t.key === tab) + + const searchMutation = useMutation({ + mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`), + onSuccess: (data) => { + setCharacters((prev) => { + if (prev.find((c) => c.character_name === data.character_name)) { + setAddError('이미 추가된 캐릭터입니다') + return prev + } + setAddError('') + setAddName('') + setSelectedCharId(data.ocid) + return [...prev, { ...data, id: data.ocid }] + }) + }, + onError: (err) => setAddError(err.message || '조회 실패'), + }) + + const handleSearch = (e) => { + e.preventDefault() + const n = addName.trim() + if (!n) return + setAddError('') + searchMutation.mutate(n) + } + + // 임시: 첫 번째 심볼만 장착된 것으로 표시 + const isEquipped = (i) => i === 0 + + return ( +
+ {/* 캐릭터 조회 */} +
+
+
+ + + + + + + { setAddName(e.target.value); if (addError) setAddError('') }} + placeholder="캐릭터 닉네임으로 장착 심볼 불러오기" + className="w-full h-12 rounded-lg border-2 border-white/10 bg-gray-950 pl-10 pr-4 text-base outline-none focus:border-emerald-500/60 hover:border-white/20 transition" + /> +
+ +
+ {addError &&

{addError}

} + + {/* 캐릭터 목록 */} + {characters.length > 0 && ( +
+ {characters.map((c) => ( + setSelectedCharId(c.id)} + onRemove={() => { + setCharacters((prev) => prev.filter((x) => x.id !== c.id)) + if (selectedCharId === c.id) setSelectedCharId(null) + }} + /> + ))} +
+ )} +
+ + {/* 심볼 타입 탭 */} +
+ {SYMBOL_TABS.map((t) => ( + + ))} +
+ + {/* 심볼 카드 그리드 */} +
+ {symbols.map((s, i) => ( + + ))} +
+ + {/* 전체 요약 */} +
+
+
{tabInfo?.label} 전체 만렙 완료 예상일
+
2026년 09월 12일 (토)
+
+
+
+
누적 체납 메소
+
108,000,000
+
+
+
+
누적 필요 메소
+
768,000,000
+
+
+
+
+ ) +} diff --git a/frontend/src/features/symbol/data.js b/frontend/src/features/symbol/data.js new file mode 100644 index 0000000..d565f07 --- /dev/null +++ b/frontend/src/features/symbol/data.js @@ -0,0 +1,30 @@ +export const SYMBOL_TABS = [ + { key: 'arcane', label: '아케인 심볼', imageName: '아케인심볼 : 소멸의 여로', maxLevel: 20 }, + { key: 'authentic', label: '어센틱 심볼', imageName: '어센틱심볼 : 세르니움', maxLevel: 11 }, + { key: 'grand', label: '그랜드 어센틱 심볼', imageName: '그랜드 어센틱심볼 : 탈라하트', maxLevel: 11 }, +] + +const BASE = 'https://s3.caadiq.co.kr/maplestory/symbol' + +export const SYMBOLS = { + arcane: [ + { key: 'yeoro', name: '소멸의 여로', image: `${BASE}/아케인심볼(소멸의 여로).webp` }, + { key: 'chuchu', name: '츄츄 아일랜드', image: `${BASE}/아케인심볼(츄츄 아일랜드).webp` }, + { key: 'lachelein', name: '레헬른', image: `${BASE}/아케인심볼(레헬른).webp` }, + { key: 'arcana', name: '아르카나', image: `${BASE}/아케인심볼(아르카나).webp` }, + { key: 'morass', name: '모라스', image: `${BASE}/아케인심볼(모라스).webp` }, + { key: 'esfera', name: '에스페라', image: `${BASE}/아케인심볼(에스페라).webp` }, + ], + authentic: [ + { key: 'cernium', name: '세르니움', image: `${BASE}/어센틱심볼(세르니움).webp` }, + { key: 'arcs', name: '아르크스', image: `${BASE}/어센틱심볼(아르크스).webp` }, + { key: 'odium', name: '오디움', image: `${BASE}/어센틱심볼(오디움).webp` }, + { key: 'dowongyeong', name: '도원경', image: `${BASE}/어센틱심볼(도원경).webp` }, + { key: 'arteria', name: '아르테리아', image: `${BASE}/어센틱심볼(아르테리아).webp` }, + { key: 'carcion', name: '카르시온', image: `${BASE}/어센틱심볼(카르시온).webp` }, + ], + grand: [ + { key: 'talahart', name: '탈라하트', image: `${BASE}/그랜드 어센틱심볼(탈라하트).webp` }, + { key: 'geardrock', name: '기어드락', image: `${BASE}/그랜드 어센틱심볼(기어드락).webp` }, + ], +}