Compare commits

...

40 commits

Author SHA1 Message Date
1dfceaf350 해방 1차/2차 라벨 라이트 모드 색상 조정
라이트 모드에서 너무 연해 보이던 purple/pink 라벨을 violet-600/rose-600으로 강화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:11:51 +09:00
46b6237f75 보스 난이도 버튼: EASY/NORMAL/HARD 테두리 보강
border 색과 bg 색이 같아 구분이 안 되던 난이도에 rgba(0,0,0,0.55) 테두리 적용
테두리 두께 1.5px로 조정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:10:09 +09:00
fe65c107c8 해방 날짜 계산기 + DatePicker 테마 토큰화
- Liberation 루트/ProgressBar/QuestSelector/WeeklyDefault/WeeklyScheduler 전체 이관
- DatePicker 드롭다운(연도/월/일 선택) 모두 semantic 토큰으로 대응
- 세그먼트 바, 배지, 탭, 초기화 버튼, 보스 Row 모두 테마 대응

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:36:58 +09:00
f0a04c51ff 심볼 계산기 페이지 테마 토큰화
- CharacterCard/SymbolCard/검색 패널/탭/요약 카드 전체 이관
- 입력/버튼/진행바/메소·체납·MAX 텍스트 모두 semantic 토큰
- equipped 아닌 심볼 카드 opacity 0.6 유지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:27:23 +09:00
b63ab39977 ConfirmDialog 테마 토큰화 + 입력 필드 테마 전환 플래시 수정
- ConfirmDialog 전체를 dialog/icon/ring/danger-btn 토큰으로 이관
- 캐릭터 닉네임 입력의 transition 제거로 테마 전환 시 검은색 플래시 해결

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:20:55 +09:00
8e4c7b8c1b 캐릭터 목록에 OverlayScrollbars 적용
스크롤바가 레이아웃을 밀지 않도록 메인 바디와 동일한 오버레이 스크롤바 사용

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:17:14 +09:00
e0dd7d1bc4 보스 수익 계산기 페이지 테마 토큰화
- Select/Tooltip 공통 컴포넌트 테마 대응
- BossCrystal 루트/CharacterPanel/BossSelector 전체 이관
- 비활성 row/난이도 버튼 테마별 처리 (--disabled-opacity, --inactive-filter)
- 라이트 테마 warning 색상 갈색 → 주황 계열로 조정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:15:04 +09:00
f89d13431a 관리자 메뉴 카드: URL 줄 제거 + 설명 2줄 표시
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:01:08 +09:00
d3db14a01c 관리자 메인 화면 너비를 홈 화면에 맞춤
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:57:09 +09:00
40d045b498 종료 임박 배지/날짜 표기 개선
- 종료 배지 'D-N' → '종료 N일 전' (시작 배지와 표현 통일)
- 이벤트 기간이 하루인 경우 '5/10 ~ 5/10' → '5/10' 단일 표기

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:54:21 +09:00
ca3c4579ae 공지 위젯 테마 토큰화 + 전환 리듬 정리
- NoticeWidget 전체를 panel/btn/badge 등 semantic 토큰으로 이관
- 캐러셀 카드 그림자가 overflow-hidden에 잘리던 문제 수정 (overflow-x-clip)
- 캐러셀 섹션 제목 크기/두께 조정, 이벤트 섹션 상단 여백 추가
- 테마 전환 시 UI 요소들 즉시 스냅, 배경만 500ms로 부드럽게 (일관된 리듬)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:23:18 +09:00
749e77774a 테마 전환 타이밍 튜닝
- 라이트 모드 새로고침 시 FOUC 방지 (index.html 블로킹 스크립트)
- 헤더 배경 제거 + backdrop-blur만 유지 → 배경과 동시 전환
- 전환 시간 300ms → 500ms로 일관되게 느리게

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:11:27 +09:00
7020794add 유틸리티 카드 테마 토큰화 + 호버/그림자 조정
- 카드/아이콘 박스/구분선에 semantic 토큰 적용
- 호버: emerald 색상 변화 대신 scale-[1.02]
- 카드 그림자 추가로 라이트 배경과 구분

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:05:33 +09:00
46ff03ced6 라이트 테마 토글 추가 (기반 구축)
- 헤더 우측 토글 버튼 + View Transition API 크로스페이드
- zustand persist 테마 스토어
- CSS 변수 기반 semantic 토큰 (:root / [data-theme="light"])
- 헤더/푸터/스크롤바(OverlayScrollbars 포함) 테마 대응

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:59:35 +09:00
4fa3bdb4a6 홈 화면 리디자인: 공지 위젯 섹션화 + 레이아웃 재배치
- 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>
2026-04-17 21:42:01 +09:00
2d43b78ce4 보스 수익/해방 계산기도 zustand로 전환
- boss-crystal/store.js: characters/selectedChar/selections + persist
- liberation/store.js: calcMode + simple/weekly slot + persist
- 세 스토어(symbol 포함)에서 version/migrate/구 localStorage 호환 코드 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:20:50 +09:00
df0bb7d14b 해방 계산기/관리자 폼 소소한 UI 조정
- 해방: 현재 진행 상태의 흔적 입력 우측에 / N 필요량 표시 (관리자 경로 입력 스타일, text-base)
- 해방: 날짜/퀘스트/흔적 3열을 1:1:1 균등 비율로 정렬
- 주차별 계산 헤더 분모를 6500 고정 → 현재 진행 기준 남은 흔적
- 보스/메뉴 관리자 폼 mx-auto 중앙 정렬

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:49:52 +09:00
791f4f8e35 캐릭터 코디/기본 정보 자동 새로고침
보스 수익 계산기/심볼 계산기에서 저장된 캐릭터의 character_image, level,
직업 정보를 페이지 로드마다 /api/character/search로 재조회해 반영.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:48:03 +09:00
3829ada3cf Nexon API 점검중 상태 감지/표시
- 백엔드: notices/character 라우트에서 OPENAPI00001/00007/00010/00011
  응답을 감지해 503 + { maintenance: true, code } 반환
- 프론트 api 클라이언트가 에러에 서버 필드(maintenance 등)를 병합
- 공지 위젯: 점검중이면 "넥슨 Open API 점검중" 안내 + retry 차단
- 캐릭터 검색 오류 메시지로 '점검중' 상황이 "존재하지 않는 캐릭터"와 구분

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:45:50 +09:00
7151315371 심볼 계산기: 효과적 만렙 처리 + 성장치 cascade 계산 수정
- 성장치가 만렙까지 cascade 가능한 경우(effectivelyMax) 완료로 취급:
  성장치에 (MAX) 표기, 남은 심볼/남은 일수/예상 완료일 '-', 입력/일퀘 버튼 비활성
- 체납 메소/남은 심볼을 성장치 cascade 방식으로 재계산:
  여러 레벨을 한 번에 올릴 수 있는 경우도 정확히 반영

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:48:00 +09:00
df057057ff UI 조정: 해방 주간 헤더 분기, 캐릭터 카드 보스 정렬
- 해방 주간 보스 설정 헤더: 단순 계산에선 합산만, 주차별에선 주간+월간/6500 분할 표시
- 보스 수익 계산기 캐릭터 카드의 보스 아이콘을 보스 목록 순서로 정렬 (12개 상한은 여전히 수익 기준으로 선발)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:07:23 +09:00
4e1d2556b2 보스 결정석 난이도 선택 UI를 CSS pill로 교체
이미지가 흐릿한 문제 해결. DIFFICULTIES 색상값을 사용해
EASY/NORMAL/HARD/CHAOS/EXTREME pill 버튼으로 직접 렌더링.

- 선택 시: 색상 풀톤
- 비선택 시: filter brightness(0.4)로 어둡게만 처리
- 크기 고정 (h-7 px-4)
- 브라우저 툴팁 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:50:37 +09:00
d7319c03a3 관리자 페이지에서 헤더 제목 클릭 시 관리자 홈으로 이동
/admin/* 라우트에서는 사이트 타이틀 링크가 /admin을 가리키도록 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:47:48 +09:00
64411b6a38 미장착 심볼 카드에서 금일 일퀘 완료 버튼 숨김
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:13:54 +09:00
2f64941817 심볼 계산기 계산 기능 + 체납 툴팁 + 탭 저장
- 완료일 계산: 매일 일퀘 + 매 목요일 주간퀘 n회분 일괄 지급으로 시뮬레이션
  (extra는 즉시 적용, 금일 일퀘 완료면 오늘 제외)
- 각 카드의 남은 일수/예상 완료일, 탭 전체의 완료 예상일 표시
- 주간퀘에 0회(0개) 옵션 추가
- 성장치 호버 시 현재 성장치로 올릴 수 있는 최대 레벨 툴팁
- 선택 탭(아케인/어센틱/그랜드 어센틱)을 캐릭터별로 persist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:06:45 +09:00
e01aa99069 심볼 계산기 계산값/API 연동/UX 보강
- /api/character/symbols 엔드포인트: Nexon API의 symbol-equipment를
  (type, region, level, growth, force) 구조로 정제 후 반환
- 프론트: useQueries로 각 캐릭터 심볼 자동 로드, 새로고침마다 갱신,
  syncCharacterSymbols로 store의 progress에 병합
- equipped 판정을 store 기반으로 전환
- 남은 심볼/필요 메소/체납 메소 실제 계산, 만렙 시 '-' 표시
- 성장치 라벨 현재 레벨 기준 표시, 만렙 시 MAX/amber 색상 + 퍼센트 숨김
- 일퀘/주간퀘/추가 심볼 비활성화 및 완료 토글 숨김 (만렙)
- 하단 요약 누적 체납/남은 필요 메소 실제 합산, 라벨 색상 통일
- 메소 값 호버 시 '억/만' 한글 축약 툴팁
- Select 비활성 상태에서 금지 커서 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:27:01 +09:00
73c024b7a7 심볼 계산기에 zustand 도입 + 캐릭터별 상태 저장
- zustand + persist 미들웨어로 캐릭터 목록·선택 상태·심볼 진행 저장
- 스토어 스키마: progress[charId][symbolId] = { level, growth, daily, weeklyCount, extra, dailyDone }
- Symbol.jsx가 localStorage useState 코드 대신 useSymbolStore 사용
- SymbolCard가 charId 기반으로 값 읽기/업데이트
- 닉네임 입력/조회 버튼 높이 정렬 (border-2 → border + box-border)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:06:01 +09:00
34a8158074 심볼 레벨 메소 BIGINT 확장
그랜드 어센틱심볼 상위 레벨 메소가 INT(2.1B) 범위를 초과하므로
SymbolLevel.meso_cost를 BIGINT로 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:59:44 +09:00
eb4369d8fb 심볼 계산기/관리자 API 연동 및 입력 확장
- 공개 /api/symbols 엔드포인트 추가 (레벨 포함)
- 심볼 계산기가 DB 데이터 기반으로 탭·카드 구성, 하드코딩 data.js 제거
- 심볼 카드 입력: 일퀘/주간퀘 Select(회→개 표기)/추가 심볼 3열
- 카드 상단에 '금일 일퀘 완료/미완료' 토글 (완료=에메랄드, 미완료=빨간색)
- 관리자 페이지: 목록/폼 실 API 연결, dnd-kit 드래그 순서 변경,
  레벨별 메소 입력 쉼표 포매팅 및 한글 요약 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:43:52 +09:00
33de50bc2d 심볼 관리자 페이지 UI + 심볼 테이블
- 모델 2개 추가: Symbol (type/region/image/max_level/daily_default/weekly_default/sort_order)
  + SymbolLevel (symbol_id/level/required_count/meso_cost)
- /admin/symbol 라우트와 심볼 목록/편집 UI (결정석 관리 스타일 차용)
- 심볼 목록 dnd-kit 드래그앤드랍 순서 변경
- 심볼 폼: 이미지 업로더, 종류/지역 입력, 만렙·일퀘·주간퀘 입력
- 레벨별 필요 개수/메소 테이블 (만렙에 따라 행 자동 조정)
- 메소 입력 쉼표 포매팅 + "N억 N,NNN만" 한글 요약 (amber, 고정 높이)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:06:30 +09:00
c9a130ea65 심볼 계산기 디자인 초안
- /symbol 경로에 Symbol 페이지 추가 (풀스크린 레이아웃)
- 아케인/어센틱/그랜드 어센틱 탭 (DB에서 대표 심볼 아이콘 가져옴)
- 캐릭터 닉네임 검색 → /api/character/search 연동 및 여러 캐릭터 추가 가능
- 캐릭터 카드: 큰 이미지 + 닉네임 + 레벨/직업 (좌우 스크롤)
- 카드 우상단 삭제 버튼
- 캐릭터 목록 + 선택 상태 localStorage 영속화
- 심볼 카드 그리드: 아이콘, 레벨, 성장치 진행바, 일퀘/주퀘 획득 입력,
  남은 심볼/필요 메소/체납 메소/남은 일수/예상 완료일 (목업)
- 하단 요약 카드: 만렙 완료 예상일 + 누적 체납 메소 + 누적 필요 메소

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:07:07 +09:00
317754206d 모바일 오버스크롤 배경 흰색 문제 해결
html/body에 background-color도 명시해 브라우저 고무줄 영역에서
흰색이 보이지 않도록 하고 overscroll-behavior-y: contain 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:28:02 +09:00
d69f4f3322 해방 계산기 보스 이미지 png → webp 전환
- data.js의 보스 이미지 확장자와 QuestSelector/ProgressBar의 URL을 webp로 변경
- QUEST_BOSS_IMAGE_BASE 경로에서 중복된 /boss 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:18:00 +09:00
aa5db4b4c2 해방 계산기 미사용 코드 정리
- WeekCard, WeeklyDesignMocks 컴포넌트 삭제 (현재 사용처 없음)
- progressByWeek/updateWeek/addWeek/removeWeek/setFirstWeekDate/totalCumulative 등
  주차별 모드 도입 전 잔여 코드 제거
- 사용하지 않는 export(addWeeks, getThursdayOfWeek) 및 import 정리
- calcMonthlyDoneEarn 미사용 함수 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:36:32 +09:00
6243dea01e 주차별 모드 해방일 계산 + 헤더/주차 행 표시 정리
- weekly 모드 시뮬레이션: 1주차는 시작일 당일에 (주간-완료) 적립,
  2주차 이후 매 목요일에 해당 주차 설정의 주간 합 적립
- 검은 마법사: 슬롯 배정에 따라 1회씩 적립(이미 done이면 제외)
- 마지막 주차 이후로는 마지막 주차 설정을 매주/매월 반복 적용
- 헤더: 주간(초록) + 월간(노랑) / 6500 형식, 모드별 합산
- 주차 행 우측: 주간/월간을 두 줄로 색상 분리 표시 (월간은 있을 때만)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:33:31 +09:00
ef8f7d5ea4 주차별 계산 탭 - 주차 추가/삭제/편집 + 영속화
- WeeklyScheduler 컴포넌트: 주차 카드 리스트, 펼침 애니메이션, 추가/삭제
- 주차 추가 시 직전 주차 설정 복사 (done 상태는 초기화)
- 마지막 한 주차는 삭제 불가
- 주차별 날짜 범위 표시 (1주차는 시작 날짜부터 다음 목요일 전일)
- 검은 마법사 월별 슬롯 배정: 한 달에 한 주차만 선점 가능, 두 달 걸치는 주차는 빈 슬롯 활용
- 새 주차 추가 시 같은 달 중복이면 검은 마법사 자동 초기화
- 1주차에만 완료/미완료 버튼 노출
- Select 드롭다운을 portal로 이동해 부모 overflow:hidden 영향 제거
- state.schedulerWeeks로 슬롯별 영속화 + 마이그레이션

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:58:42 +09:00
d1ca41ed4a 해방 주차별 계산 탭 초안
- 계산 모드 탭(단순/주차별)을 상단으로 이동, 각 모드 독립 slot 저장
- 초기화 시 현재 모드 slot만 초기화, 다른 모드는 유지
- 주차별 카드 리스트 + 펼침 편집 영역 목업
- 편집 영역에서 기존 BossRow 재사용 (완료 버튼은 현재 주차에만)
- 검은 마법사 행 항상 표시, 같은 달 다른 주차 배정 시 비활성

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:41:53 +09:00
6ca610d014 공지 위젯 펼침/탭 전환 애니메이션 부드럽게
- 더보기/접기를 framer-motion height + opacity + translateY로 전환
- 탭 변경 시 AnimatePresence mode="wait"로 페이드+슬라이드 전환

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:15:55 +09:00
f7481acfa3 캐릭터 패널 보스 툴팁 순서 변경
보스 이름 · 난이도 → 난이도 · 보스 이름 순으로 표시하여 난이도 인지를 우선시.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:19:04 +09:00
d3fda95d04 해방 계산기 UI 다듬기
- 섹션 폭 max-w-3xl로 통일
- ProgressBar 초상화 테두리 제거, 세그먼트/초상화 간격 gap-2
- 1차 해방 라벨 색상을 에메랄드와 구분되는 보라(#a78bfa)로
- 예상 해방 날짜 텍스트 크기 키우고 요일 표시
- DatePicker 선택 날짜에 요일 표시
- Select 드롭다운이 아래 공간 부족하면 위로 펼침
- Select 옵션 패딩 py-2.5로 키움
- 주간 보스 설정 보스 초상화(w-10)·이름(text-base)·행 높이(h-16) 키움

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:22:20 +09:00
44 changed files with 4164 additions and 960 deletions

View file

@ -2,6 +2,8 @@ import { Image } from './Image.js';
import { Menu } from './Menu.js';
import { BossCrystalBoss } from './boss-crystal/Boss.js';
import { BossCrystalBossDifficulty } from './boss-crystal/BossDifficulty.js';
import { Symbol } from './symbol/Symbol.js';
import { SymbolLevel } from './symbol/SymbolLevel.js';
// Menu <-> Image
Menu.belongsTo(Image, { foreignKey: 'image_id', as: 'image', onDelete: 'SET NULL' });
@ -14,4 +16,12 @@ BossCrystalBoss.hasMany(BossCrystalBossDifficulty, {
});
BossCrystalBossDifficulty.belongsTo(BossCrystalBoss, { foreignKey: 'boss_id', as: 'boss' });
export { Image, Menu, BossCrystalBoss, BossCrystalBossDifficulty };
// Symbol <-> SymbolLevel
Symbol.hasMany(SymbolLevel, {
foreignKey: 'symbol_id',
as: 'levels',
onDelete: 'CASCADE',
});
SymbolLevel.belongsTo(Symbol, { foreignKey: 'symbol_id', as: 'symbol' });
export { Image, Menu, BossCrystalBoss, BossCrystalBossDifficulty, Symbol, SymbolLevel };

View file

@ -0,0 +1,23 @@
import { DataTypes } from 'sequelize';
import { sequelize } from '../../lib/db.js';
export const Symbol = sequelize.define('Symbol', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
type: {
type: DataTypes.ENUM('아케인', '어센틱', '그랜드 어센틱'),
allowNull: false,
},
region: { type: DataTypes.STRING(32), allowNull: false },
image: { type: DataTypes.STRING(255), allowNull: true },
max_level: { type: DataTypes.TINYINT, allowNull: false },
daily_default: { type: DataTypes.SMALLINT, allowNull: false, defaultValue: 0 },
weekly_default: { type: DataTypes.SMALLINT, allowNull: false, defaultValue: 0 },
sort_order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
}, {
tableName: 'sym_symbols',
underscored: true,
indexes: [
{ unique: true, fields: ['type', 'region'] },
{ fields: ['sort_order'] },
],
});

View file

@ -0,0 +1,16 @@
import { DataTypes } from 'sequelize';
import { sequelize } from '../../lib/db.js';
export const SymbolLevel = sequelize.define('SymbolLevel', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
symbol_id: { type: DataTypes.INTEGER, allowNull: false },
level: { type: DataTypes.TINYINT, allowNull: false },
required_count: { type: DataTypes.SMALLINT, allowNull: false },
meso_cost: { type: DataTypes.BIGINT, allowNull: false },
}, {
tableName: 'sym_levels',
underscored: true,
indexes: [
{ unique: true, fields: ['symbol_id', 'level'] },
],
});

View file

@ -5,6 +5,7 @@ import { convertAndUpload, deleteFromS3 } from '../services/image.js';
import { getPublicUrl } from '../lib/s3.js';
import { sequelize } from '../lib/db.js';
import bossCrystalRouter from './admin/boss-crystal.js';
import symbolRouter from './admin/symbol.js';
const router = Router();
const upload = multer({
@ -35,6 +36,7 @@ router.use(requireAdmin);
// 기능별 sub-router
router.use('/boss-crystal', bossCrystalRouter);
router.use('/symbol', symbolRouter);
/* ── 이미지 관리 ── */

View file

@ -0,0 +1,237 @@
import { Router } from 'express';
import multer from 'multer';
import { Symbol, SymbolLevel } from '../../models/index.js';
import { convertAndUploadTo, deleteFromS3 } from '../../services/image.js';
import { getPublicUrl } from '../../lib/s3.js';
import { sequelize } from '../../lib/db.js';
const router = Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
});
const VALID_TYPES = ['아케인', '어센틱', '그랜드 어센틱'];
function imagePath(type, region) {
return `symbol/${type}심볼(${region}).webp`;
}
function serialize(symbol) {
const json = symbol.toJSON();
return {
id: json.id,
type: json.type,
region: json.region,
image: json.image,
image_url: json.image ? getPublicUrl(json.image) : null,
max_level: json.max_level,
daily_default: json.daily_default,
weekly_default: json.weekly_default,
sort_order: json.sort_order,
levels: (json.levels || [])
.sort((a, b) => a.level - b.level)
.map((l) => ({
level: l.level,
required_count: l.required_count,
meso_cost: Number(l.meso_cost),
})),
};
}
function parseLevels(raw, maxLevel) {
if (!raw) return [];
let arr;
try {
arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
throw new Error('레벨 정보 형식이 잘못되었습니다');
}
if (!Array.isArray(arr)) throw new Error('레벨 정보는 배열이어야 합니다');
return arr.map((l) => {
const level = Number(l.level);
const required_count = Number(l.required_count);
const meso_cost = Number(l.meso_cost);
if (!level || level < 1 || level >= maxLevel) {
throw new Error(`잘못된 레벨: ${l.level}`);
}
if (isNaN(required_count) || required_count < 0) {
throw new Error(`잘못된 필요 개수: Lv.${level}`);
}
if (isNaN(meso_cost) || meso_cost < 0) {
throw new Error(`잘못된 메소: Lv.${level}`);
}
return { level, required_count, meso_cost };
});
}
function validateBasic({ type, region, max_level }) {
if (!VALID_TYPES.includes(type)) throw new Error('잘못된 심볼 종류입니다');
const r = String(region || '').trim();
if (!r) throw new Error('지역 이름을 입력해주세요');
const ml = Number(max_level);
if (!ml || ml < 2 || ml > 99) throw new Error('만렙은 2~99 사이여야 합니다');
return { type, region: r, max_level: ml };
}
// 목록
router.get('/symbols', async (_req, res) => {
try {
const rows = await Symbol.findAll({
order: [['sort_order', 'ASC'], ['id', 'ASC']],
include: [{ model: SymbolLevel, as: 'levels' }],
});
res.json(rows.map(serialize));
} catch (err) {
console.error('심볼 목록 조회 오류:', err.message);
res.status(500).json({ error: '심볼 목록 조회 실패' });
}
});
// 단건
router.get('/symbols/:id', async (req, res) => {
try {
const row = await Symbol.findByPk(req.params.id, {
include: [{ model: SymbolLevel, as: 'levels' }],
});
if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' });
res.json(serialize(row));
} catch (err) {
console.error('심볼 조회 오류:', err.message);
res.status(500).json({ error: '심볼 조회 실패' });
}
});
// 생성
router.post('/symbols', upload.single('image'), async (req, res) => {
const t = await sequelize.transaction();
try {
const basic = validateBasic(req.body);
const levels = parseLevels(req.body.levels, basic.max_level);
const daily_default = Number(req.body.daily_default) || 0;
const weekly_default = Number(req.body.weekly_default) || 0;
if (!req.file) throw new Error('심볼 이미지를 업로드해주세요');
const key = imagePath(basic.type, basic.region);
await convertAndUploadTo(req.file.buffer, key);
const maxOrder = (await Symbol.max('sort_order')) || 0;
const created = await Symbol.create({
type: basic.type,
region: basic.region,
image: key,
max_level: basic.max_level,
daily_default,
weekly_default,
sort_order: maxOrder + 1,
}, { transaction: t });
if (levels.length) {
await SymbolLevel.bulkCreate(
levels.map((l) => ({ symbol_id: created.id, ...l })),
{ transaction: t }
);
}
await t.commit();
const full = await Symbol.findByPk(created.id, { include: [{ model: SymbolLevel, as: 'levels' }] });
res.status(201).json(serialize(full));
} catch (err) {
await t.rollback();
console.error('심볼 생성 오류:', err.message);
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(409).json({ error: '이미 등록된 종류+지역 조합입니다' });
}
res.status(400).json({ error: err.message || '심볼 생성 실패' });
}
});
// 수정
router.patch('/symbols/:id', upload.single('image'), async (req, res) => {
const t = await sequelize.transaction();
try {
const row = await Symbol.findByPk(req.params.id);
if (!row) { await t.rollback(); return res.status(404).json({ error: '심볼을 찾을 수 없습니다' }); }
const basic = validateBasic({
type: req.body.type ?? row.type,
region: req.body.region ?? row.region,
max_level: req.body.max_level ?? row.max_level,
});
let imageKey = row.image;
if (req.file) {
imageKey = imagePath(basic.type, basic.region);
await convertAndUploadTo(req.file.buffer, imageKey);
if (row.image && row.image !== imageKey) {
try { await deleteFromS3(row.image); } catch { /* ignore */ }
}
} else if (basic.type !== row.type || basic.region !== row.region) {
// 이름/종류 변경 시 새 경로로 rename 대체 불가 → 기존 키 유지
imageKey = row.image;
}
await row.update({
type: basic.type,
region: basic.region,
max_level: basic.max_level,
image: imageKey,
daily_default: Number(req.body.daily_default) || 0,
weekly_default: Number(req.body.weekly_default) || 0,
}, { transaction: t });
if (req.body.levels !== undefined) {
const levels = parseLevels(req.body.levels, basic.max_level);
await SymbolLevel.destroy({ where: { symbol_id: row.id }, transaction: t });
if (levels.length) {
await SymbolLevel.bulkCreate(
levels.map((l) => ({ symbol_id: row.id, ...l })),
{ transaction: t }
);
}
}
await t.commit();
const full = await Symbol.findByPk(row.id, { include: [{ model: SymbolLevel, as: 'levels' }] });
res.json(serialize(full));
} catch (err) {
await t.rollback();
console.error('심볼 수정 오류:', err.message);
res.status(400).json({ error: err.message || '심볼 수정 실패' });
}
});
// 삭제
router.delete('/symbols/:id', async (req, res) => {
try {
const row = await Symbol.findByPk(req.params.id);
if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' });
const key = row.image;
await row.destroy();
if (key) { try { await deleteFromS3(key); } catch { /* ignore */ } }
res.json({ success: true });
} catch (err) {
console.error('심볼 삭제 오류:', err.message);
res.status(500).json({ error: '심볼 삭제 실패' });
}
});
// 순서 변경
router.post('/symbols/reorder', async (req, res) => {
const { ids } = req.body;
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids 배열이 필요합니다' });
const t = await sequelize.transaction();
try {
for (let i = 0; i < ids.length; i++) {
await Symbol.update({ sort_order: i + 1 }, { where: { id: ids[i] }, transaction: t });
}
await t.commit();
res.json({ success: true });
} catch (err) {
await t.rollback();
console.error('심볼 순서 변경 오류:', err.message);
res.status(500).json({ error: '순서 변경 실패' });
}
});
export default router;

View file

@ -31,6 +31,10 @@ router.get('/search', async (req, res) => {
character_image: basic.character_image,
});
} catch (err) {
const code = err.response?.data?.error?.name;
if (['OPENAPI00001', 'OPENAPI00007', 'OPENAPI00010', 'OPENAPI00011'].includes(code)) {
return res.status(503).json({ error: 'API 점검중입니다', code, maintenance: true });
}
if (err.response?.status === 400) {
return res.status(404).json({ error: '존재하지 않는 캐릭터입니다' });
}
@ -39,4 +43,39 @@ router.get('/search', async (req, res) => {
}
});
// OCID로 장착 심볼 조회
router.get('/symbols', async (req, res) => {
const { ocid } = req.query;
if (!ocid) return res.status(400).json({ error: 'ocid가 필요합니다' });
try {
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/symbol-equipment`, {
params: { ocid },
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
});
const parsed = (data.symbol || []).map((s) => {
const [prefix, region] = (s.symbol_name || '').split(' : ').map((t) => t.trim());
const type = prefix?.replace(/심볼$/, '').trim(); // '아케인심볼' → '아케인'
return {
type,
region,
level: Number(s.symbol_level) || 0,
force: Number(s.symbol_force) || 0,
growth_count: Number(s.symbol_growth_count) || 0,
require_growth_count: Number(s.symbol_require_growth_count) || 0,
};
});
res.json({ ocid, character_class: data.character_class, symbols: parsed });
} catch (err) {
const code = err.response?.data?.error?.name;
if (['OPENAPI00001', 'OPENAPI00007', 'OPENAPI00010', 'OPENAPI00011'].includes(code)) {
return res.status(503).json({ error: 'API 점검중입니다', code, maintenance: true });
}
console.error('심볼 조회 오류:', err.response?.data || err.message);
res.status(500).json({ error: '심볼 조회 실패' });
}
});
export default router;

View file

@ -23,6 +23,13 @@ router.get('/', async (req, res) => {
});
res.json(data);
} catch (err) {
const errData = err.response?.data?.error;
const code = errData?.name;
// Nexon 점검 코드: OPENAPI00001(게임 점검), OPENAPI00007(api 점검), OPENAPI00011(오픈 API 점검)
const underMaintenance = ['OPENAPI00001', 'OPENAPI00007', 'OPENAPI00010', 'OPENAPI00011'].includes(code);
if (underMaintenance) {
return res.status(503).json({ error: 'API 점검중입니다', code, maintenance: true });
}
console.error(`공지 조회 오류 (${type}):`, err.response?.data || err.message);
res.status(500).json({ error: '공지 조회 실패' });
}

39
backend/routes/symbol.js Normal file
View file

@ -0,0 +1,39 @@
import { Router } from 'express';
import { Symbol, SymbolLevel } from '../models/index.js';
import { getPublicUrl } from '../lib/s3.js';
const router = Router();
router.get('/', async (_req, res) => {
try {
const rows = await Symbol.findAll({
order: [['sort_order', 'ASC'], ['id', 'ASC']],
include: [{ model: SymbolLevel, as: 'levels' }],
});
res.json(rows.map((s) => {
const j = s.toJSON();
return {
id: j.id,
type: j.type,
region: j.region,
image_url: j.image ? getPublicUrl(j.image) : null,
max_level: j.max_level,
daily_default: j.daily_default,
weekly_default: j.weekly_default,
sort_order: j.sort_order,
levels: (j.levels || [])
.sort((a, b) => a.level - b.level)
.map((l) => ({
level: l.level,
required_count: l.required_count,
meso_cost: Number(l.meso_cost),
})),
};
}));
} catch (err) {
console.error('심볼 목록 조회 오류:', err.message);
res.status(500).json({ error: '심볼 목록 조회 실패' });
}
});
export default router;

View file

@ -6,6 +6,7 @@ import noticeRoutes from './routes/notices.js';
import bossCrystalRoutes from './routes/boss-crystal.js';
import characterRoutes from './routes/character.js';
import imageRoutes from './routes/images.js';
import symbolRoutes from './routes/symbol.js';
import { sequelize } from './lib/db.js';
import './models/index.js';
@ -25,6 +26,7 @@ app.use('/api/notices', noticeRoutes);
app.use('/api/boss-crystal', bossCrystalRoutes);
app.use('/api/character', characterRoutes);
app.use('/api/images', imageRoutes);
app.use('/api/symbols', symbolRoutes);
app.use('/api/admin', adminRoutes);
app.get('/api/health', (_req, res) => {

View file

@ -29,3 +29,14 @@ export async function convertAndUpload(buffer) {
export async function deleteFromS3(path) {
await deleteObject(path);
}
/**
* 지정한 경로로 webp 변환 업로드 (덮어쓰기)
* @param {Buffer} buffer - 원본 이미지 버퍼
* @param {string} path - S3 (확장자 포함). : 'symbol/아케인심볼(소멸의 여로).webp'
*/
export async function convertAndUploadTo(buffer, path) {
const webpBuffer = await sharp(buffer).webp({ quality: 90 }).toBuffer();
await uploadObject(path, webpBuffer, 'image/webp');
return { path, size: webpBuffer.length };
}

View file

@ -9,6 +9,16 @@
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;900&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/gh/fonts-archive/Maplestory/Maplestory.css" rel="stylesheet" />
<script type="text/javascript" src="https://openapi.nexon.com/js/analytics.js?app_id=274844" async></script>
<script>
(function () {
try {
var raw = localStorage.getItem('maple-theme');
if (!raw) return;
var theme = JSON.parse(raw).state && JSON.parse(raw).state.theme;
if (theme === 'light') document.documentElement.setAttribute('data-theme', 'light');
} catch (e) {}
})();
</script>
<title>메이플스토리 유틸리티</title>
</head>
<body>

View file

@ -18,7 +18,8 @@
"overlayscrollbars-react": "^0.5.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"
"react-router-dom": "^7.14.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@ -3139,6 +3140,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View file

@ -20,7 +20,8 @@
"overlayscrollbars-react": "^0.5.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"
"react-router-dom": "^7.14.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",

View file

@ -16,7 +16,9 @@ export async function api(url, options = {}) {
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || `HTTP ${res.status}`)
const e = new Error(error.error || `HTTP ${res.status}`)
Object.assign(e, error, { status: res.status })
throw e
}
return res.json()

View file

@ -12,8 +12,18 @@ export default function ConfirmDialog({
loading = false,
}) {
const accent = destructive
? { ring: 'ring-red-500/20', icon: 'text-red-300', iconBg: 'bg-red-500/10 border-red-500/30' }
: { ring: 'ring-emerald-500/20', icon: 'text-emerald-300', iconBg: 'bg-emerald-500/10 border-emerald-500/30' }
? {
ringColor: 'var(--ring-danger)',
iconColor: 'var(--danger-text)',
iconBg: 'var(--icon-danger-bg)',
iconBorder: 'var(--icon-danger-border)',
}
: {
ringColor: 'var(--ring-info)',
iconColor: 'var(--accent-bright)',
iconBg: 'var(--icon-info-bg)',
iconBorder: 'var(--icon-info-border)',
}
return (
<AnimatePresence>
@ -24,7 +34,8 @@ export default function ConfirmDialog({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-md"
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-md"
style={{ background: 'var(--dialog-backdrop)' }}
onClick={onClose}
>
<motion.div
@ -33,48 +44,82 @@ export default function ConfirmDialog({
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 4 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className={`w-full max-w-md rounded-2xl bg-gradient-to-b from-gray-900 to-gray-950 border border-white/10 shadow-2xl ring-1 ${accent.ring}`}
className="w-full max-w-md rounded-2xl border shadow-2xl ring-1"
style={{
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
borderColor: 'var(--dialog-border)',
'--tw-ring-color': accent.ringColor,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="px-7 pt-7 pb-3 flex items-start gap-4">
<div className={`shrink-0 w-11 h-11 rounded-xl border flex items-center justify-center ${accent.iconBg}`}>
<div
className="shrink-0 w-11 h-11 rounded-xl border flex items-center justify-center"
style={{ background: accent.iconBg, borderColor: accent.iconBorder, color: accent.iconColor }}
>
{destructive ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" className={accent.icon}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M12 9V13M12 17H12.01M10.29 3.86L1.82 18C1.64 18.31 1.55 18.67 1.55 19.03C1.55 19.4 1.65 19.76 1.83 20.07C2 20.39 2.26 20.65 2.57 20.83C2.88 21.01 3.24 21.1 3.6 21.1H20.47C20.83 21.1 21.19 21.01 21.5 20.83C21.81 20.65 22.07 20.39 22.24 20.07C22.42 19.76 22.52 19.4 22.52 19.03C22.52 18.67 22.43 18.31 22.25 18L13.78 3.86C13.6 3.56 13.35 3.31 13.04 3.14C12.74 2.96 12.4 2.87 12.06 2.87C11.72 2.87 11.38 2.96 11.08 3.14C10.77 3.31 10.52 3.56 10.34 3.86H10.29Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" className={accent.icon}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M12 8V12M12 16H12.01M22 12C22 17.52 17.52 22 12 22C6.48 22 2 17.52 2 12C2 6.48 6.48 2 12 2C17.52 2 22 6.48 22 12Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
<h3 className="flex-1 text-xl font-bold text-white pt-1.5">{title}</h3>
<h3
className="flex-1 text-xl font-bold pt-1.5"
style={{ color: 'var(--text-strong)' }}
>
{title}
</h3>
<button
onClick={onClose}
className="shrink-0 w-8 h-8 -mt-1 -mr-1 rounded-lg text-gray-500 hover:text-white hover:bg-white/5 transition flex items-center justify-center text-xl leading-none"
className="shrink-0 w-8 h-8 -mt-1 -mr-1 rounded-lg hover:bg-[var(--row-hover-bg)] flex items-center justify-center text-xl leading-none"
style={{ color: 'var(--text-dim)' }}
aria-label="닫기"
>
×
</button>
</div>
<div className="px-7 pt-4 pb-7">
<p className="text-lg text-gray-300 leading-relaxed whitespace-pre-line">{description}</p>
<p
className="text-lg leading-relaxed whitespace-pre-line"
style={{ color: 'var(--text-muted)' }}
>
{description}
</p>
</div>
<div className="flex gap-2 px-7 py-4 border-t border-white/5">
<div
className="flex gap-2 px-7 py-4 border-t"
style={{ borderColor: 'var(--panel-border)' }}
>
<button
onClick={onClose}
className="flex-1 rounded-lg border border-white/10 bg-white/[0.02] hover:bg-white/[0.06] text-gray-200 px-4 h-11 text-sm font-medium transition"
className="flex-1 rounded-lg border px-4 h-11 text-sm font-medium hover:bg-[var(--btn-bg-hover)] hover:border-[var(--btn-border-hover)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
{cancelText}
</button>
<button
onClick={onConfirm}
disabled={loading}
className={`flex-1 rounded-lg px-4 h-11 text-sm font-semibold transition disabled:opacity-50 ${
destructive
? 'bg-red-600 hover:bg-red-500 text-white shadow-lg shadow-red-500/20'
: 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20'
}`}
className="flex-1 rounded-lg px-4 h-11 text-sm font-semibold disabled:opacity-50"
style={{
background: destructive ? 'var(--btn-danger-bg)' : 'var(--btn-primary-bg)',
color: destructive ? 'var(--btn-primary-text)' : 'var(--btn-primary-text)',
boxShadow: destructive ? 'var(--btn-danger-shadow)' : 'var(--btn-primary-shadow)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = destructive ? 'var(--btn-danger-bg-hover)' : 'var(--btn-primary-bg-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = destructive ? 'var(--btn-danger-bg)' : 'var(--btn-primary-bg)'
}}
>
{loading ? '처리 중...' : confirmText}
</button>

View file

@ -69,10 +69,12 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
const selectYear = (y) => setViewDate(new Date(y, month, 1))
const selectMonth = (m) => { setViewDate(new Date(year, m, 1)); setViewMode('days') }
const DOW = ['일', '월', '화', '수', '목', '금', '토']
const formatDisplay = (s) => {
if (!s) return ''
const [y, m, d] = s.split('-')
return `${y}${parseInt(m)}${parseInt(d)}`
const dow = DOW[new Date(`${s}T00:00:00+09:00`).getDay()]
return `${y}${parseInt(m)}${parseInt(d)}일 (${dow})`
}
const isSelected = (day) => {
@ -95,14 +97,19 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
<button
type="button"
onClick={(e) => stop(e, () => setIsOpen(!isOpen))}
className={`w-full h-12 rounded-lg border bg-gray-950 px-4 text-base flex items-center justify-between transition ${
isOpen ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
}`}
className="w-full h-12 rounded-lg border px-4 text-base flex items-center justify-between"
style={{
background: 'var(--input-bg)',
borderColor: isOpen ? 'var(--input-border-focus)' : 'var(--input-border)',
}}
>
<span className={value ? 'text-white' : 'text-gray-500'}>
<span style={{ color: value ? 'var(--text-strong)' : 'var(--input-placeholder)' }}>
{value ? formatDisplay(value) : placeholder}
</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" className="text-gray-400">
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none"
style={{ color: 'var(--input-icon)' }}
>
<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="2" />
<path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" strokeWidth="2" />
</svg>
@ -115,22 +122,29 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.15 }}
className="absolute z-50 mt-2 left-0 rounded-xl border border-white/10 bg-gray-900 shadow-2xl p-5"
style={{ width: 420 }}
className="absolute z-50 mt-2 left-0 rounded-xl border p-5"
style={{
width: 420,
background: 'var(--popup-bg)',
borderColor: 'var(--popup-border)',
boxShadow: 'var(--popup-shadow)',
}}
>
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={(e) => stop(e, viewMode === 'years' ? prevYearRange : prevMonth)}
disabled={viewMode === 'years' ? !canGoPrevYearRange : (year === minYear && month === 0)}
className="p-1.5 rounded hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed text-gray-400"
className="p-1.5 rounded hover:bg-[var(--row-hover-bg)] disabled:opacity-30 disabled:cursor-not-allowed"
style={{ color: 'var(--text-muted)' }}
>
<ChevronIcon dir="left" size={18} />
</button>
<button
type="button"
onClick={(e) => stop(e, () => setViewMode(viewMode === 'days' ? 'years' : 'days'))}
className="flex items-center gap-1 text-sm font-medium text-gray-200 hover:text-emerald-300 transition"
className="flex items-center gap-1 text-sm font-medium hover:text-[var(--accent-bright)]"
style={{ color: 'var(--text-emphasis)' }}
>
{viewMode === 'years' ? `${years[0]} - ${years[years.length - 1]}` : `${year}${month + 1}`}
<ChevronIcon dir={viewMode !== 'days' ? 'up' : 'down'} size={14} className="transition-transform" />
@ -138,7 +152,8 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
<button
type="button"
onClick={(e) => stop(e, viewMode === 'years' ? nextYearRange : nextMonth)}
className="p-1.5 rounded hover:bg-white/5 text-gray-400"
className="p-1.5 rounded hover:bg-[var(--row-hover-bg)]"
style={{ color: 'var(--text-muted)' }}
>
<ChevronIcon dir="right" size={18} />
</button>
@ -147,43 +162,51 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
<AnimatePresence mode="wait">
{viewMode === 'years' ? (
<motion.div key="years" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.12 }}>
<div className="text-center text-xs text-gray-500 mb-2">연도</div>
<div className="text-center text-xs mb-2" style={{ color: 'var(--text-dim)' }}>연도</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: '6px', marginBottom: '12px' }}>
{years.map((y) => (
<button
key={y}
type="button"
onClick={(e) => stop(e, () => selectYear(y))}
className={`py-2 rounded-lg text-sm transition ${
year === y
? 'bg-emerald-500 text-white'
: currentYear === y
? 'text-emerald-300 hover:bg-white/5'
: 'text-gray-300 hover:bg-white/5'
}`}
>
{y}
</button>
))}
{years.map((y) => {
const isActive = year === y
const isCurrent = currentYear === y && !isActive
return (
<button
key={y}
type="button"
onClick={(e) => stop(e, () => selectYear(y))}
className="py-2 rounded-lg text-sm hover:bg-[var(--row-hover-bg)]"
style={isActive ? {
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
} : {
color: isCurrent ? 'var(--accent-bright)' : 'var(--text-emphasis)',
}}
>
{y}
</button>
)
})}
</div>
<div className="text-center text-xs text-gray-500 mb-2"></div>
<div className="text-center text-xs mb-2" style={{ color: 'var(--text-dim)' }}></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: '6px' }}>
{monthNames.map((m, i) => (
<button
key={m}
type="button"
onClick={(e) => stop(e, () => selectMonth(i))}
className={`py-2 rounded-lg text-sm transition ${
month === i
? 'bg-emerald-500 text-white'
: (currentYear === year && currentMonth === i)
? 'text-emerald-300 hover:bg-white/5'
: 'text-gray-300 hover:bg-white/5'
}`}
>
{m}
</button>
))}
{monthNames.map((m, i) => {
const isActive = month === i
const isCurrent = (currentYear === year && currentMonth === i) && !isActive
return (
<button
key={m}
type="button"
onClick={(e) => stop(e, () => selectMonth(i))}
className="py-2 rounded-lg text-sm hover:bg-[var(--row-hover-bg)]"
style={isActive ? {
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
} : {
color: isCurrent ? 'var(--accent-bright)' : 'var(--text-emphasis)',
}}
>
{m}
</button>
)
})}
</div>
</motion.div>
) : (
@ -192,9 +215,11 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
<div
key={d}
className={`text-center text-xs font-medium py-1 ${
i === 0 ? 'text-red-400/80' : i === 6 ? 'text-sky-400/80' : 'text-gray-500'
}`}
className="text-center text-xs font-medium py-1"
style={{
color: i === 0 ? 'var(--danger-text)' : i === 6 ? '#60a5fa' : 'var(--text-dim)',
opacity: i === 0 || i === 6 ? 0.8 : 1,
}}
>
{d}
</div>
@ -205,20 +230,25 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
const dw = i % 7
const selected = isSelected(day)
const today = isToday(day)
const textColor = today && !selected ? 'var(--accent-bright)'
: day && !selected && !today && dw === 0 ? 'var(--danger-text)'
: day && !selected && !today && dw === 6 ? '#60a5fa'
: day && !selected && !today ? 'var(--text-emphasis)'
: undefined
return (
<button
key={i}
type="button"
disabled={!day}
onClick={(e) => day && stop(e, () => selectDate(day))}
style={{ aspectRatio: '1 / 1' }}
className={`rounded-full text-base font-medium flex items-center justify-center transition-all
${!day ? '' : 'hover:bg-white/5'}
${selected ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}
${today && !selected ? 'text-emerald-300 font-bold' : ''}
${day && !selected && !today && dw === 0 ? 'text-red-400' : ''}
${day && !selected && !today && dw === 6 ? 'text-sky-400' : ''}
${day && !selected && !today && dw > 0 && dw < 6 ? 'text-gray-300' : ''}
style={{
aspectRatio: '1 / 1',
background: selected ? 'var(--btn-primary-bg)' : undefined,
color: selected ? 'var(--btn-primary-text)' : textColor,
fontWeight: today && !selected ? 'bold' : undefined,
}}
className={`rounded-full text-base font-medium flex items-center justify-center
${!day ? '' : 'hover:bg-[var(--row-hover-bg)]'}
`}
>
{day}

View file

@ -1,18 +1,24 @@
export default function Footer() {
return (
<footer className="border-t border-white/5 mt-16">
<footer
className="border-t mt-16"
style={{ borderColor: 'var(--header-border)' }}
>
<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
className="grid gap-2 sm:grid-cols-2 text-xs"
style={{ color: 'var(--text-dim)' }}
>
<div className="space-y-1">
<p>This site is not associated with NEXON Korea.</p>
<p>
Data based on{' '}
<a href="https://openapi.nexon.com" target="_blank" rel="noopener noreferrer" className="text-emerald-400/80 hover:text-emerald-300 transition">
<a href="https://openapi.nexon.com" target="_blank" rel="noopener noreferrer" className="text-emerald-500 hover:text-emerald-400 transition">
NEXON Open API
</a>
.

View file

@ -1,8 +1,9 @@
import { createContext, useContext, useState, useEffect } from 'react'
import { Outlet, Link, useLocation } from 'react-router-dom'
import { Outlet, Link, useLocation, useMatch } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import Footer from './Footer'
import { useThemeStore } from '../stores/theme'
const SITE_NAME = '메이플스토리 유틸리티'
@ -40,30 +41,96 @@ function CurrentMenuTitle() {
if (!menu) return null
return (
<div className="flex items-center gap-3 text-gray-400">
<span className="text-white/20">/</span>
<div
className="flex items-center gap-3"
style={{ color: 'var(--text-muted)' }}
>
<span style={{ color: 'var(--text-slash)' }}>/</span>
<div className="flex items-center gap-2">
{menu.image?.url && (
<img src={menu.image.url} alt="" className="w-5 h-5 object-contain" />
)}
<span className="text-sm font-medium text-gray-200">{menu.title}</span>
<span
className="text-sm font-medium"
style={{ color: 'var(--text-emphasis)' }}
>
{menu.title}
</span>
</div>
</div>
)
}
function ThemeToggle() {
const theme = useThemeStore((s) => s.theme)
const toggleTheme = useThemeStore((s) => s.toggleTheme)
const isLight = theme === 'light'
const handleToggle = () => toggleTheme()
return (
<button
type="button"
onClick={handleToggle}
aria-label={isLight ? '다크 모드로 전환' : '라이트 모드로 전환'}
title={isLight ? '다크 모드' : '라이트 모드'}
className="relative inline-flex h-8 w-14 items-center rounded-full border transition-colors duration-500 hover:border-emerald-500/40"
style={{
background: 'var(--toggle-bg)',
borderColor: 'var(--toggle-border)',
}}
>
<span
className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full shadow-md transition-[transform,background] duration-300 ease-out"
style={{
transform: isLight ? 'translateX(24px)' : 'translateX(0px)',
backgroundImage: 'linear-gradient(to bottom right, var(--toggle-thumb-from), var(--toggle-thumb-to))',
color: 'var(--toggle-thumb-icon)',
}}
>
{isLight ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
<path fillRule="evenodd" d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zM10 15a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zM18 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0118 10zM5 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 015 10zM15.657 4.343a.75.75 0 010 1.06l-1.06 1.061a.75.75 0 11-1.061-1.06l1.06-1.061a.75.75 0 011.061 0zM6.464 13.536a.75.75 0 010 1.06l-1.06 1.061a.75.75 0 01-1.061-1.06l1.06-1.061a.75.75 0 011.061 0zM15.657 15.657a.75.75 0 01-1.06 0l-1.061-1.06a.75.75 0 011.06-1.061l1.061 1.06a.75.75 0 010 1.061zM6.464 6.464a.75.75 0 01-1.06 0L4.343 5.404a.75.75 0 011.06-1.06l1.061 1.06a.75.75 0 010 1.06zM10 6a4 4 0 100 8 4 4 0 000-8z" clipRule="evenodd" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
<path fillRule="evenodd" d="M7.455 2.004a.75.75 0 01.26.77 7 7 0 009.958 7.967.75.75 0 011.067.853A8.5 8.5 0 116.647 1.921a.75.75 0 01.808.083z" clipRule="evenodd" />
</svg>
)}
</span>
</button>
)
}
export default function Layout() {
const [fullscreen, setFullscreen] = useState(false)
const isAdmin = !!useMatch('/admin/*')
const homeTo = isAdmin ? '/admin' : '/'
const theme = useThemeStore((s) => s.theme)
useEffect(() => {
const root = document.documentElement
if (theme === 'light') root.setAttribute('data-theme', 'light')
else root.removeAttribute('data-theme')
}, [theme])
return (
<LayoutContext.Provider value={{ fullscreen, setFullscreen }}>
<div className={`min-w-[1280px] text-white flex flex-col ${
fullscreen ? 'h-dvh' : 'min-h-screen'
}`}>
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md shrink-0">
<div
className={`min-w-[1280px] flex flex-col ${
fullscreen ? 'h-dvh' : 'min-h-screen'
}`}
style={{ color: 'var(--text-strong)' }}
>
<header
className="sticky top-0 z-20 border-b backdrop-blur-md shrink-0"
style={{
borderColor: 'var(--header-border)',
}}
>
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Link to="/" className="group flex items-center gap-2.5">
<Link to={homeTo} className="group flex items-center gap-2.5">
<img src="/favicon.ico" alt="" className="w-8 h-8" />
<span className="text-lg font-bold tracking-tight">
메이플스토리 유틸리티
@ -71,6 +138,7 @@ export default function Layout() {
</Link>
<CurrentMenuTitle />
</div>
<ThemeToggle />
</div>
</header>
<main className={`flex-1 mx-auto w-full max-w-[1400px] ${

View file

@ -1,201 +1,431 @@
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}일 전`, 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 overflow-hidden flex flex-col"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div
className="px-4 py-3 border-b"
style={{ borderColor: 'var(--panel-border)' }}
>
<h3
className="text-sm font-bold"
style={{ color: 'var(--text-emphasis)' }}
>
{cfg.label}
</h3>
</div>
<div className="relative overflow-hidden">
{isLoading ? (
<div
className="p-8 text-center text-sm"
style={{ color: 'var(--text-dim)' }}
>
불러오는 ...
</div>
) : isMaintenance ? (
<div className="p-8 text-center">
<div
className="text-sm font-medium"
style={{ color: 'var(--maintenance-text)' }}
>
넥슨 Open API 점검중
</div>
</div>
) : slice.length === 0 ? (
<div
className="p-8 text-center text-sm"
style={{ color: 'var(--text-dim)' }}
>
등록된 항목이 없습니다
</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"
style={{ '--tw-divide-opacity': 1, borderColor: 'var(--row-divider)' }}
>
{slice.map((it) => (
<li
key={it.notice_id}
className="flex items-center gap-2 border-t first:border-t-0"
style={{ borderColor: 'var(--row-divider)' }}
>
<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 transition hover:bg-[var(--row-hover-bg)]"
>
{isRecent(it.date) && (
<span
className="shrink-0 inline-flex items-center justify-center w-4 h-4 rounded-full text-[9px] font-bold"
style={{ background: 'var(--accent)', color: 'var(--badge-text)' }}
>
N
</span>
)}
<span
className="flex-1 min-w-0 text-[13px] truncate transition-colors hover:text-[var(--accent-hover-text)]"
style={{ color: 'var(--text-muted)' }}
>
{it.title}
</span>
<span
className="shrink-0 text-[11px] tabular-nums"
style={{ color: 'var(--text-dim)' }}
>
{fmtYMD(it.date)}
</span>
</a>
</li>
))}
</motion.ul>
</AnimatePresence>
)}
</div>
{pages > 1 && (
<div
className="flex items-center justify-between border-t px-4 py-3 text-sm"
style={{ borderColor: 'var(--panel-border)', color: 'var(--text-muted)' }}
>
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={clamped === 0}
className="inline-flex items-center gap-1.5 transition hover:text-[var(--text-strong)] disabled:opacity-30 disabled:hover:text-[var(--text-muted)]"
>
<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"
style={{
background: i === clamped ? 'var(--accent)' : 'var(--dot-inactive)',
}}
onMouseEnter={(e) => {
if (i !== clamped) e.currentTarget.style.background = 'var(--dot-inactive-hover)'
}}
onMouseLeave={(e) => {
if (i !== clamped) e.currentTarget.style.background = 'var(--dot-inactive)'
}}
/>
))}
</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 transition hover:text-[var(--text-strong)] disabled:opacity-30 disabled:hover:text-[var(--text-muted)]"
>
다음
<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 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 (
<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 border"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
{notice.thumbnail_url && (
<div className="shrink-0 w-20 h-20 rounded-md bg-gray-900 overflow-hidden">
<div
className="aspect-[2/1] overflow-hidden"
style={{ background: 'var(--thumb-bg)' }}
>
{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-transform duration-500"
loading="lazy"
/>
) : (
<div
className="w-full h-full flex items-center justify-center text-4xl"
style={{ color: 'var(--thumb-placeholder)' }}
>
📢
</div>
)}
{badge && (
<span
className="absolute top-2 right-2 px-2 py-0.5 rounded-full text-[11px] font-medium"
style={{ background: badgeBg, color: 'var(--badge-text)' }}
>
{badge.label}
</span>
)}
</div>
<div className="p-3 space-y-1">
<div
className="text-sm font-medium line-clamp-1 transition-colors group-hover:text-[var(--accent-hover-text)]"
style={{ color: 'var(--text-emphasis)' }}
>
{item.title}
</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
className="text-xs tabular-nums"
style={{ color: 'var(--text-dim)' }}
>
{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)
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 { 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)
}
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 (
<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) => (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h3
className="text-base font-medium"
style={{ color: 'var(--text-emphasis)' }}
>
{cfg.label}
</h3>
{pages > 1 && (
<div
className="flex items-center gap-3 text-sm"
style={{ color: 'var(--text-muted)' }}
>
<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'
}`}
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={clamped === 0}
className={navBtn}
aria-label="이전"
>
{t.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>
))}
</div>
<span className="tabular-nums min-w-[48px] text-center">
<span style={{ color: 'var(--text-emphasis)' }}>{clamped + 1}</span>
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>/</span>
{pages}
</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}
disabled={clamped >= pages - 1}
className={navBtn}
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="p-3">
<div className="relative overflow-x-clip pb-2">
{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 animate-pulse"
style={{ background: 'var(--skeleton-bg)' }}
/>
))}
</div>
) : initialItems.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
{tab.filterOngoing ? `진행중인 ${tab.label}이 없습니다` : `등록된 ${tab.label}이 없습니다`}
) : isMaintenance ? (
<div
className="py-10 rounded-xl border text-center"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div
className="text-sm font-medium"
style={{ color: 'var(--maintenance-text)' }}
>
넥슨 Open API 점검중
</div>
</div>
) : slice.length === 0 ? (
<div
className="py-10 rounded-xl border border-dashed text-center text-sm"
style={{ borderColor: 'var(--dashed-border)', color: 'var(--text-dim)' }}
>
{cfg.filterOngoing ? `진행중인 ${cfg.label}이 없습니다` : `등록된 ${cfg.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' : ''}`}
<AnimatePresence mode="wait" initial={false}>
<motion.div
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"
>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{slice.map((it) => <CardItem key={it.notice_id} item={it} cfg={cfg} />)}
</motion.div>
</AnimatePresence>
)}
</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>
<div className="pt-2">
<CarouselSection cfg={SECTIONS.event} {...byKey.event} />
</div>
<CarouselSection cfg={SECTIONS.cashshop} {...byKey.cashshop} />
</section>
)
}

View file

@ -1,78 +1,144 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
/**
* 커스텀 드롭다운 셀렉트
* <Select value={x} onChange={...} options={[{value, label}]} />
* 커스텀 드롭다운 셀렉트 (포털로 렌더링 부모 overflow:hidden에도 잘림 없음)
*/
export default function Select({ value, onChange, options, disabled, className = '', placeholder = '선택', align = 'left' }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
const [flipUp, setFlipUp] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
const buttonRef = useRef(null)
const popupRef = useRef(null)
const updatePosition = () => {
if (!buttonRef.current) return
const rect = buttonRef.current.getBoundingClientRect()
const estHeight = Math.min(options.length * 44 + 8, 240)
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
const flip = spaceBelow < estHeight && spaceAbove > spaceBelow
setFlipUp(flip)
setPos({
top: flip ? rect.top : rect.bottom,
left: rect.left,
width: rect.width,
bottomOffset: flip ? window.innerHeight - rect.top : 0,
})
}
useLayoutEffect(() => {
if (open) updatePosition()
}, [open])
useEffect(() => {
if (!open) return
const handler = (e) => {
if (!ref.current?.contains(e.target)) setOpen(false)
const onDown = (e) => {
if (buttonRef.current?.contains(e.target)) return
if (popupRef.current?.contains(e.target)) return
setOpen(false)
}
const onScroll = () => updatePosition()
document.addEventListener('mousedown', onDown)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
return () => {
document.removeEventListener('mousedown', onDown)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const selected = options.find((o) => o.value === value)
return (
<div ref={ref} className={`relative ${className}`}>
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setOpen((v) => !v)}
className={`w-full flex items-center justify-between gap-2 rounded-lg border bg-gray-950 px-3 py-2 text-sm transition outline-none ${
open ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className={selected ? '' : 'text-gray-500'}>
{selected ? selected.label : placeholder}
</span>
<svg className={`w-3.5 h-3.5 text-gray-500 transition ${open ? 'rotate-180' : ''}`} viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.98 }}
transition={{ duration: 0.15 }}
className={`absolute top-full mt-1 z-20 min-w-full rounded-lg border border-white/10 bg-gray-900 shadow-xl overflow-hidden origin-top ${
align === 'right' ? 'right-0' : 'left-0'
}`}
>
<div className="max-h-60 overflow-y-auto py-1">
{options.map((opt) => (
const popup = (
<AnimatePresence>
{open && (
<motion.div
ref={popupRef}
initial={{ opacity: 0, y: flipUp ? 6 : -6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: flipUp ? 6 : -6, scale: 0.98 }}
transition={{ duration: 0.15 }}
className={`fixed z-[100] rounded-lg border overflow-hidden ${
flipUp ? 'origin-bottom' : 'origin-top'
}`}
style={{
background: 'var(--popup-bg)',
borderColor: 'var(--popup-border)',
boxShadow: 'var(--popup-shadow)',
color: 'var(--text-strong)',
...(flipUp
? { bottom: pos.bottomOffset + 4, left: pos.left, minWidth: pos.width }
: { top: pos.top + 4, left: pos.left, minWidth: pos.width }
),
}}
>
<div className="max-h-60 overflow-y-auto py-1">
{options.map((opt) => {
const isActive = opt.value === value
return (
<button
key={opt.value}
type="button"
onClick={() => { onChange(opt.value); setOpen(false) }}
className={`w-full text-left px-3 py-1.5 text-sm transition flex items-center gap-2 ${
opt.value === value
? 'bg-emerald-500/10 text-emerald-300'
: 'hover:bg-white/5'
}`}
className="w-full text-left px-3 py-2.5 text-sm flex items-center gap-2"
style={isActive ? {
background: 'var(--option-selected-bg)',
color: 'var(--option-selected-text)',
} : undefined}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.background = 'var(--row-hover-bg)'
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.background = ''
}}
>
{opt.value === value && (
{isActive && (
<svg className="w-3 h-3 shrink-0" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6L5 8.5L9.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
<span className={opt.value !== value ? 'pl-5' : ''}>{opt.label}</span>
<span className={!isActive ? 'pl-5' : ''}>{opt.label}</span>
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
)
return (
<div className={`relative ${className}`}>
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={() => !disabled && setOpen((v) => !v)}
className={`w-full flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm outline-none ${
disabled ? 'opacity-50 !cursor-default' : ''
}`}
style={{
background: 'var(--input-bg)',
borderColor: open ? 'var(--input-border-focus)' : 'var(--input-border)',
color: 'var(--text-strong)',
}}
>
<span style={{ color: selected ? 'var(--text-strong)' : 'var(--input-placeholder)' }}>
{selected ? selected.label : placeholder}
</span>
<svg
className={`w-3.5 h-3.5 transition ${open ? 'rotate-180' : ''}`}
style={{ color: 'var(--input-icon)' }}
viewBox="0 0 12 12"
fill="none"
>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{createPortal(popup, document.body)}
</div>
)
}

View file

@ -98,8 +98,11 @@ export default function Tooltip({ text, children, placement = 'top', delay = 200
zIndex: 9999,
opacity: coords ? 1 : 0,
transition: 'opacity 120ms ease-out',
background: 'var(--tooltip-bg)',
color: 'var(--tooltip-text)',
borderColor: 'var(--tooltip-border)',
}}
className="pointer-events-none px-2 py-1 rounded-md bg-gray-900 border border-white/10 text-xs text-gray-200 shadow-lg whitespace-nowrap"
className="pointer-events-none px-2 py-1 rounded-md border text-xs shadow-lg whitespace-nowrap"
>
{text}
</div>,

View file

@ -4,7 +4,6 @@ import { api } from '../../api/client'
function MenuCard({ menu }) {
const navigate = useNavigate()
// url slug (/boss-crystal boss-crystal)
const slug = (menu.url || '').replace(/^\/+/, '').split('/')[0]
const adminPath = slug ? `/admin/${slug}` : `/admin/menus/${menu.id}`
@ -17,14 +16,16 @@ function MenuCard({ menu }) {
return (
<Link
to={adminPath}
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-5 hover:border-emerald-500/30 hover:from-emerald-500/5 hover:to-cyan-500/5 transition-all duration-300"
className="group relative rounded-2xl border p-5 transition-transform duration-300 hover:scale-[1.02] border-[var(--card-border)]"
style={{
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',
boxShadow: 'var(--card-shadow)',
}}
>
<div className="absolute -top-12 -right-12 w-32 h-32 rounded-full bg-emerald-500/0 group-hover:bg-emerald-500/10 blur-2xl transition-all duration-500" />
{/* 톱니바퀴 - 메뉴 정보 편집 */}
<button
onClick={handleEditClick}
className="absolute top-3 right-3 w-8 h-8 rounded-lg border border-white/5 hover:border-white/20 hover:bg-white/5 text-gray-500 hover:text-gray-300 flex items-center justify-center text-sm transition opacity-0 group-hover:opacity-100 z-10"
className="absolute top-3 right-3 w-8 h-8 rounded-lg border flex items-center justify-center text-sm opacity-0 group-hover:opacity-100 transition-opacity z-10 border-[var(--btn-border)] bg-[var(--btn-bg)] hover:bg-[var(--btn-bg-hover)]"
style={{ color: 'var(--text-dim)' }}
title="메뉴 정보 편집"
aria-label="메뉴 정보 편집"
>
@ -32,13 +33,20 @@ function MenuCard({ menu }) {
</button>
<div className="relative flex items-start gap-4">
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden group-hover:scale-110 group-hover:border-emerald-500/30 transition-all duration-300">
<div
className="shrink-0 w-12 h-12 rounded-xl border flex items-center justify-center overflow-hidden border-[var(--icon-box-border)]"
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
>
<img src={menu.image?.url || '/default.png'} alt={menu.title} className="w-9 h-9 object-contain" />
</div>
<div className="flex-1 min-w-0 pr-8">
<h3 className="font-semibold text-white group-hover:text-emerald-300 transition truncate">{menu.title}</h3>
<p className="text-sm text-gray-400 mt-1 leading-relaxed truncate">{menu.description}</p>
<p className="text-xs text-gray-600 mt-1 font-mono truncate">{menu.url}</p>
<h3 className="font-medium truncate">{menu.title}</h3>
<p
className="text-sm mt-1 leading-relaxed line-clamp-2"
style={{ color: 'var(--text-muted)' }}
>
{menu.description}
</p>
</div>
</div>
</Link>
@ -49,12 +57,16 @@ function AddCard({ to, icon, label }) {
return (
<Link
to={to}
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-white/10 hover:border-emerald-500/40 bg-white/[0.02] hover:bg-emerald-500/5 p-5 min-h-[112px] transition-all"
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed p-5 min-h-[112px] transition-transform duration-300 hover:scale-[1.02] border-[var(--dashed-border)]"
style={{ background: 'var(--skeleton-bg)' }}
>
<div className="w-10 h-10 rounded-full border border-white/10 group-hover:border-emerald-500/40 flex items-center justify-center text-gray-500 group-hover:text-emerald-400 transition">
<div
className="w-10 h-10 rounded-full border flex items-center justify-center border-[var(--dashed-border)]"
style={{ color: 'var(--text-dim)' }}
>
{icon}
</div>
<span className="text-sm text-gray-500 group-hover:text-emerald-300 transition">{label}</span>
<span className="text-sm" style={{ color: 'var(--text-dim)' }}>{label}</span>
</Link>
)
}
@ -67,20 +79,29 @@ export default function AdminHome() {
})
return (
<div className="space-y-8">
<div className="space-y-8 max-w-5xl mx-auto pt-6">
{/* 메뉴 섹션 */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">기능 관리</h2>
<p className="text-sm text-gray-500 mt-0.5">메뉴 항목을 추가하거나 관리합니다</p>
<h2 className="text-lg font-medium">기능 관리</h2>
<p
className="text-sm mt-0.5"
style={{ color: 'var(--text-dim)' }}
>
메뉴 항목을 추가하거나 관리합니다
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-28 rounded-2xl bg-white/[0.02] animate-pulse" />
<div
key={i}
className="h-28 rounded-2xl animate-pulse"
style={{ background: 'var(--skeleton-bg)' }}
/>
))
) : (
<>
@ -96,27 +117,41 @@ export default function AdminHome() {
{/* 자원 관리 섹션 */}
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold">자원 관리</h2>
<p className="text-sm text-gray-500 mt-0.5">공용 이미지 사이트 자원을 관리합니다</p>
<h2 className="text-lg font-medium">자원 관리</h2>
<p
className="text-sm mt-0.5"
style={{ color: 'var(--text-dim)' }}
>
공용 이미지 사이트 자원을 관리합니다
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Link
to="/admin/images"
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-5 hover:border-cyan-500/30 hover:from-cyan-500/5 hover:to-blue-500/5 transition-all duration-300"
className="group relative rounded-2xl border p-5 transition-transform duration-300 hover:scale-[1.02] border-[var(--card-border)]"
style={{
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',
boxShadow: 'var(--card-shadow)',
}}
>
<div className="absolute -top-12 -right-12 w-32 h-32 rounded-full bg-cyan-500/0 group-hover:bg-cyan-500/10 blur-2xl transition-all duration-500" />
<div className="relative flex items-start gap-4">
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center text-2xl group-hover:scale-110 group-hover:border-cyan-500/30 transition-all duration-300">
<div
className="shrink-0 w-12 h-12 rounded-xl border flex items-center justify-center text-2xl border-[var(--icon-box-border)]"
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
>
🖼
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold group-hover:text-cyan-300 transition">이미지 관리</h3>
<p className="text-sm text-gray-400 mt-1 leading-relaxed">공용 이미지 업로드 관리</p>
</div>
<div className="text-gray-700 group-hover:text-cyan-400 group-hover:translate-x-1 transition-all duration-300">
<h3 className="font-medium">이미지 관리</h3>
<p
className="text-sm mt-1 leading-relaxed"
style={{ color: 'var(--text-muted)' }}
>
공용 이미지 업로드 관리
</p>
</div>
<div style={{ color: 'var(--text-dim)' }}></div>
</div>
</Link>
</div>
@ -127,7 +162,8 @@ export default function AdminHome() {
<div className="pt-4 text-center">
<button
onClick={handleLogout}
className="text-xs text-gray-600 hover:text-red-400 transition"
className="text-xs transition-colors hover:text-red-500"
style={{ color: 'var(--text-dim)' }}
>
관리자 로그아웃
</button>

View file

@ -119,7 +119,7 @@ export default function AdminMenuForm() {
})
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6 max-w-2xl mx-auto">
<div>
<h2 className="text-lg font-semibold">{isEdit ? '메뉴 항목 편집' : '메뉴 항목 추가'}</h2>
<p className="text-sm text-gray-500 mt-0.5"> 화면에 표시되는 카드의 정보를 설정합니다</p>

View file

@ -1,36 +1,23 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useQuery, useQueries } from '@tanstack/react-query'
import { api } from '../../api/client'
import { useLayout } from '../../components/Layout'
import CharacterPanel from './user/CharacterPanel'
import BossSelector from './user/BossSelector'
import { useBossStore } from './store'
const STORAGE_CHARS = 'maple-bc-characters'
const STORAGE_SELECTIONS = 'maple-bc-selections'
const MAX_PER_CHARACTER = 12
export default function BossCrystal() {
const [characters, setCharacters] = useState(() => {
const saved = localStorage.getItem(STORAGE_CHARS)
return saved ? JSON.parse(saved) : []
})
const [selectedChar, setSelectedChar] = useState(() => {
const saved = localStorage.getItem(STORAGE_CHARS)
const list = saved ? JSON.parse(saved) : []
return list[0]?.character_name || null
})
const [allSelections, setAllSelections] = useState(() => {
const saved = localStorage.getItem(STORAGE_SELECTIONS)
return saved ? JSON.parse(saved) : {}
})
useEffect(() => {
localStorage.setItem(STORAGE_CHARS, JSON.stringify(characters))
}, [characters])
useEffect(() => {
localStorage.setItem(STORAGE_SELECTIONS, JSON.stringify(allSelections))
}, [allSelections])
const characters = useBossStore((s) => s.characters)
const selectedChar = useBossStore((s) => s.selectedChar)
const selections = useBossStore((s) => s.selections)
const addCharacter = useBossStore((s) => s.addCharacter)
const removeCharacter = useBossStore((s) => s.removeCharacter)
const selectCharacter = useBossStore((s) => s.selectCharacter)
const reorderCharacters = useBossStore((s) => s.reorderCharacters)
const setBossSelection = useBossStore((s) => s.setBossSelection)
const updateCharacter = useBossStore((s) => s.updateCharacter)
// ( + )
const { setFullscreen } = useLayout()
@ -44,71 +31,79 @@ export default function BossCrystal() {
queryFn: () => api('/api/boss-crystal/bosses').catch(() => []),
})
const handleAddCharacter = (char) => {
setCharacters((prev) => [...prev, char])
setSelectedChar(char.character_name)
}
//
const charRefreshQueries = useQueries({
queries: characters.map((c) => ({
queryKey: ['character', 'basic', c.character_name],
queryFn: () => api(`/api/character/search?name=${encodeURIComponent(c.character_name)}`),
enabled: !!c.character_name,
refetchOnMount: 'always',
staleTime: 0,
retry: false,
})),
})
const handleRemoveCharacter = (name) => {
setCharacters((prev) => {
const next = prev.filter((c) => c.character_name !== name)
if (selectedChar === name) {
setSelectedChar(next[0]?.character_name || null)
useEffect(() => {
characters.forEach((c, i) => {
const d = charRefreshQueries[i]?.data
if (!d) return
if (d.character_image !== c.character_image || d.character_level !== c.character_level || d.job_name !== c.job_name) {
updateCharacter(c.character_name, {
character_image: d.character_image,
character_level: d.character_level,
job_name: d.job_name,
world_name: d.world_name,
ocid: d.ocid,
})
}
return next
})
setAllSelections((prev) => {
const next = { ...prev }
delete next[name]
return next
})
}
const handleReorderCharacters = (next) => {
setCharacters(next)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [charRefreshQueries.map((q) => q.dataUpdatedAt).join(',')])
const handleBossChange = (bossId, sel) => {
if (!selectedChar) return
setAllSelections((prev) => {
const charSel = { ...(prev[selectedChar] || {}) }
if (sel === null) {
delete charSel[bossId]
} else {
charSel[bossId] = sel
}
return { ...prev, [selectedChar]: charSel }
})
setBossSelection(selectedChar, bossId, sel)
}
const currentSelections = selectedChar ? (allSelections[selectedChar] || {}) : {}
const currentSelections = selectedChar ? (selections[selectedChar] || {}) : {}
const currentSelectedCount = Object.values(currentSelections).filter(Boolean).length
const isMaxReached = currentSelectedCount >= MAX_PER_CHARACTER
return (
<div className="h-full">
{isLoading ? (
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-16 text-center">
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin mx-auto" />
<div
className="rounded-2xl border p-16 text-center"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div className="w-6 h-6 border-2 border-t-transparent rounded-full animate-spin mx-auto" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
</div>
) : (
<div className="grid gap-4 lg:grid-cols-[420px_1fr] h-full min-h-0">
{/* 좌측: 캐릭터 + 결과 통합 (총 수익/추가 고정 + 목록 스크롤) */}
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-4 min-h-0 max-h-full self-start overflow-hidden flex flex-col">
<div
className="rounded-2xl border p-4 min-h-0 max-h-full self-start overflow-hidden flex flex-col"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<CharacterPanel
characters={characters}
selectedName={selectedChar}
allSelections={allSelections}
allSelections={selections}
bosses={bosses}
onSelect={setSelectedChar}
onAdd={handleAddCharacter}
onRemove={handleRemoveCharacter}
onReorder={handleReorderCharacters}
onSelect={selectCharacter}
onAdd={addCharacter}
onRemove={removeCharacter}
onReorder={reorderCharacters}
/>
</div>
{/* 우측: 보스 선택 (헤더 고정 + 목록 스크롤) */}
<div className="min-h-0">
<BossSelector
characterName={selectedChar}

View file

@ -174,7 +174,7 @@ export default function BossForm() {
const displayImage = imagePreview || existingImageUrl
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6 max-w-2xl mx-auto">
<div>
<h2 className="text-lg font-semibold">{isEdit ? '보스 편집' : '보스 추가'}</h2>
<p className="text-sm text-gray-500 mt-0.5">보스 이름과 난이도별 결정 정보를 입력합니다</p>

View file

@ -0,0 +1,55 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* 보스 수익 계산기 상태
* characters: [{ character_name, character_image, character_level, job_name, ... }]
* selectedChar: 선택된 캐릭터 닉네임
* selections: { [character_name]: { [bossId]: { difficulty, party } } }
*/
export const useBossStore = create(persist(
(set) => ({
characters: [],
selectedChar: null,
selections: {},
setCharacters: (next) => set((s) => ({
characters: typeof next === 'function' ? next(s.characters) : next,
})),
addCharacter: (char) => set((s) => {
if (s.characters.find((c) => c.character_name === char.character_name)) return s
return {
characters: [...s.characters, char],
selectedChar: char.character_name,
}
}),
removeCharacter: (name) => set((s) => {
const next = s.characters.filter((c) => c.character_name !== name)
const nextSel = { ...s.selections }
delete nextSel[name]
return {
characters: next,
selections: nextSel,
selectedChar: s.selectedChar === name ? (next[0]?.character_name || null) : s.selectedChar,
}
}),
selectCharacter: (name) => set({ selectedChar: name }),
updateCharacter: (name, patch) => set((s) => ({
characters: s.characters.map((c) => (c.character_name === name ? { ...c, ...patch } : c)),
})),
reorderCharacters: (next) => set({ characters: next }),
setBossSelection: (charName, bossId, sel) => set((s) => {
const charSel = { ...(s.selections[charName] || {}) }
if (sel === null) delete charSel[bossId]
else charSel[bossId] = sel
return { selections: { ...s.selections, [charName]: charSel } }
}),
}),
{ name: 'maple-boss-crystal' },
))

View file

@ -1,11 +1,19 @@
import Select from '../../../components/Select'
import Tooltip from '../../../components/Tooltip'
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from '../admin/constants'
import { DIFFICULTIES, formatMeso } from '../admin/constants'
const LABEL_EN = { easy: 'EASY', normal: 'NORMAL', hard: 'HARD', chaos: 'CHAOS', extreme: 'EXTREME' }
export default function BossSelector({ characterName, bosses, selections, onChange, maxReached, selectedCount, maxPerCharacter }) {
if (!characterName) {
return (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center text-sm text-gray-500">
<div
className="rounded-2xl border border-dashed p-16 text-center text-sm"
style={{
borderColor: 'var(--dashed-border)',
background: 'var(--skeleton-bg)',
color: 'var(--text-dim)',
}}
>
좌측에서 캐릭터를 선택해주세요
</div>
)
@ -13,16 +21,37 @@ export default function BossSelector({ characterName, bosses, selections, onChan
if (bosses.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center text-sm text-gray-500">
<div
className="rounded-2xl border border-dashed p-16 text-center text-sm"
style={{
borderColor: 'var(--dashed-border)',
background: 'var(--skeleton-bg)',
color: 'var(--text-dim)',
}}
>
등록된 보스가 없습니다
</div>
)
}
return (
<div className="rounded-xl border border-white/5 bg-gray-900/40 overflow-hidden flex flex-col h-full">
<div
className="rounded-xl border overflow-hidden flex flex-col h-full"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
{/* 헤더 (고정) */}
<div className="flex items-center gap-3 px-3 py-3 bg-gray-950/60 border-b border-white/5 text-base font-semibold text-gray-300 shrink-0">
<div
className="flex items-center gap-3 px-3 py-3 border-b text-base font-medium shrink-0"
style={{
background: 'var(--surface-2)',
borderColor: 'var(--panel-border)',
color: 'var(--text-emphasis)',
}}
>
<div className="w-52 shrink-0">보스</div>
<div className="flex-1">난이도</div>
<div className="w-20 shrink-0 text-center">파티원 </div>
@ -30,7 +59,7 @@ export default function BossSelector({ characterName, bosses, selections, onChan
</div>
{/* 목록 (스크롤) */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="divide-y divide-white/5">
<div className="divide-y" style={{ '--tw-divide-opacity': 1 }}>
{bosses.map((boss) => {
const availableDiffs = DIFFICULTIES.filter((d) =>
boss.difficulties.some((bd) => bd.difficulty === d.key)
@ -51,13 +80,20 @@ export default function BossSelector({ characterName, bosses, selections, onChan
return (
<div
key={boss.id}
className={`flex items-center gap-3 px-3 py-3 transition ${
disabled ? 'opacity-30 pointer-events-none' : ''
className={`flex items-center gap-3 px-3 py-3 border-t first:border-t-0 ${
disabled ? 'pointer-events-none' : ''
}`}
style={{
borderColor: 'var(--panel-border)',
opacity: disabled ? 'var(--disabled-opacity)' : 1,
}}
>
{/* 보스 이미지 + 이름 */}
<div className="flex items-center gap-2.5 w-52 shrink-0">
<div className="shrink-0 w-11 h-11 rounded-lg bg-gray-900 overflow-hidden">
<div
className="shrink-0 w-11 h-11 rounded-lg overflow-hidden"
style={{ background: 'var(--surface-nested)' }}
>
<img src={boss.image_url || '/default.png'} alt={boss.name} className="w-full h-full object-cover" />
</div>
<span className="text-base font-medium leading-tight whitespace-nowrap overflow-hidden text-ellipsis">{boss.name}</span>
@ -67,24 +103,30 @@ export default function BossSelector({ characterName, bosses, selections, onChan
<div className="flex-1 flex items-center gap-2 flex-nowrap min-w-0">
{availableDiffs.map((d) => {
const active = sel?.difficulty === d.key
const hasVisibleBorder = d.colors.border !== d.colors.bg
const borderColor = hasVisibleBorder ? d.colors.border : 'rgba(0, 0, 0, 0.55)'
const style = {
background: d.colors.bg,
borderColor,
borderWidth: '1.5px',
color: d.colors.text,
filter: active ? 'none' : 'var(--inactive-filter)',
}
return (
<Tooltip key={d.key} text={d.label}>
<button
type="button"
tabIndex={-1}
onClick={(e) => {
e.currentTarget.blur()
if (active) {
onChange(boss.id, null)
} else {
onChange(boss.id, { difficulty: d.key, party: partyN })
}
}}
className={`shrink-0 transition focus:outline-none ${active ? 'opacity-100 scale-105' : 'opacity-40 hover:opacity-70'}`}
>
<img src={getDifficultyImageUrl(d.key)} alt={d.label} className="h-5" />
</button>
</Tooltip>
<button
key={d.key}
type="button"
tabIndex={-1}
onClick={(e) => {
e.currentTarget.blur()
if (active) onChange(boss.id, null)
else onChange(boss.id, { difficulty: d.key, party: partyN })
}}
style={style}
className="shrink-0 rounded-full border-solid px-4 h-7 text-xs font-bold tracking-wider transition focus:outline-none"
>
{LABEL_EN[d.key] || d.key.toUpperCase()}
</button>
)
})}
</div>
@ -99,12 +141,20 @@ export default function BossSelector({ characterName, bosses, selections, onChan
align="right"
/>
) : (
<div className="text-xs text-gray-700 text-center">-</div>
<div
className="text-xs text-center"
style={{ color: 'var(--text-dim)' }}
>
-
</div>
)}
</div>
{/* 수익 */}
<div className={`w-32 shrink-0 text-right text-sm font-medium tabular-nums ${sel ? 'text-emerald-300' : 'text-gray-700'}`}>
<div
className="w-32 shrink-0 text-right text-sm font-medium tabular-nums"
style={{ color: sel ? 'var(--accent-bright)' : 'var(--text-dim)' }}
>
{sel ? formatMeso(revenue) : '-'}
</div>
</div>

View file

@ -1,6 +1,7 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Reorder, useDragControls } from 'framer-motion'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import { api } from '../../../api/client'
import ConfirmDialog from '../../../components/ConfirmDialog'
import Tooltip from '../../../components/Tooltip'
@ -11,6 +12,7 @@ const MAX_PER_CHARACTER = 12
const MAX_PER_ACCOUNT = 90
function CharacterContent({ char, selections, bosses }) {
const bossIndex = new Map(bosses.map((b, i) => [b.id, i]))
const selectedBosses = Object.entries(selections || {})
.filter(([, sel]) => sel)
.map(([bossId, sel]) => {
@ -25,9 +27,12 @@ function CharacterContent({ char, selections, bosses }) {
}
})
.filter(Boolean)
.sort((a, b) => b.revenue - a.revenue)
const visibleBosses = selectedBosses.slice(0, MAX_PER_CHARACTER)
// 12 ,
const topByRevenue = [...selectedBosses].sort((a, b) => b.revenue - a.revenue).slice(0, MAX_PER_CHARACTER)
const visibleBosses = topByRevenue.sort(
(a, b) => (bossIndex.get(a.boss.id) ?? 0) - (bossIndex.get(b.boss.id) ?? 0)
)
const totalRevenue = visibleBosses.reduce((s, x) => s + x.revenue, 0)
const count = selectedBosses.length
@ -44,14 +49,16 @@ function CharacterContent({ char, selections, bosses }) {
draggable={false}
/>
) : (
<span className="text-gray-700 text-4xl">?</span>
<span className="text-4xl" style={{ color: 'var(--text-dim)' }}>?</span>
)}
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-base font-semibold truncate">{char.character_name}</span>
<span className="text-xs text-gray-500 truncate">Lv.{char.character_level} · {char.job_name}</span>
<span className="text-xs truncate" style={{ color: 'var(--text-dim)' }}>
Lv.{char.character_level} · {char.job_name}
</span>
</div>
{visibleBosses.length > 0 ? (
@ -61,10 +68,16 @@ function CharacterContent({ char, selections, bosses }) {
return (
<Tooltip
key={item.boss.id}
text={`${item.boss.name} ${diff?.label || ''} · ${formatMeso(item.revenue)}`}
text={`${diff?.label || ''} ${item.boss.name} · ${formatMeso(item.revenue)}`}
>
<div className="space-y-0.5">
<div className="aspect-square rounded bg-gray-900 overflow-hidden border border-white/5">
<div
className="aspect-square rounded overflow-hidden border"
style={{
background: 'var(--surface-nested)',
borderColor: 'var(--panel-border)',
}}
>
<img src={item.boss.image_url || '/default.png'} alt="" draggable={false} className="w-full h-full object-cover select-none" />
</div>
<div className="flex justify-center">
@ -81,17 +94,38 @@ function CharacterContent({ char, selections, bosses }) {
})}
</div>
) : (
<div className="text-xs text-gray-600 italic h-[58px] flex items-center">보스 미선택</div>
<div
className="text-xs italic h-[58px] flex items-center"
style={{ color: 'var(--text-dim)' }}
>
보스 미선택
</div>
)}
</div>
</div>
<div className="flex items-center justify-between border-t border-white/5 pt-2">
<div
className="flex items-center justify-between border-t pt-2"
style={{ borderColor: 'var(--panel-border)' }}
>
<div className="flex items-baseline gap-1 tabular-nums">
<span className={`text-base font-bold ${count > 0 ? 'text-amber-300' : 'text-gray-600'}`}>{count}</span>
<span className="text-base font-bold text-amber-300/40">/ {MAX_PER_CHARACTER}</span>
<span
className="text-base font-bold"
style={{ color: count > 0 ? 'var(--warning-text-bright)' : 'var(--text-dim)' }}
>
{count}
</span>
<span
className="text-base font-bold"
style={{ color: count > 0 ? 'var(--warning-text-dim)' : 'var(--text-dim)' }}
>
/ {MAX_PER_CHARACTER}
</span>
</div>
<div className={`text-sm font-semibold tabular-nums whitespace-nowrap ${count > 0 ? 'text-emerald-300' : 'text-gray-700'}`}>
<div
className="text-sm font-semibold tabular-nums whitespace-nowrap"
style={{ color: count > 0 ? 'var(--accent-bright)' : 'var(--text-dim)' }}
>
{count > 0 ? formatMeso(totalRevenue) : '-'}
</div>
</div>
@ -118,17 +152,17 @@ function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemov
if (e.target.closest('button')) return
onSelect(char.character_name)
}}
className={`group relative rounded-xl border cursor-pointer select-none ${
isSelected
? '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'
}`}
className="group relative rounded-xl border cursor-pointer select-none"
style={{
borderColor: isSelected ? 'var(--selected-border)' : 'var(--panel-border)',
background: isSelected ? 'var(--selected-bg)' : 'var(--surface-3)',
}}
>
{/* 드래그 핸들 */}
<div
onPointerDown={(e) => { e.preventDefault(); dragControls.start(e) }}
className="absolute left-0 top-0 bottom-0 w-8 flex items-center justify-center text-gray-600 hover:text-gray-400 cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none' }}
className="absolute left-0 top-0 bottom-0 w-8 flex items-center justify-center cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none', color: 'var(--text-dim)' }}
>
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
<circle cx="3" cy="3" r="1.2" />
@ -143,7 +177,8 @@ function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemov
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove(char) }}
className="absolute top-2 right-2 z-10 w-6 h-6 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition opacity-0 group-hover:opacity-100 flex items-center justify-center text-base"
className="absolute top-2 right-2 z-10 w-6 h-6 rounded opacity-0 group-hover:opacity-100 flex items-center justify-center text-base hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)]"
style={{ color: 'var(--text-dim)' }}
aria-label="삭제"
>
×
@ -218,13 +253,20 @@ export default function CharacterPanel({
return (
<div className="flex flex-col gap-4 min-h-0 flex-1">
{/* 총 수익 카드 (고정) */}
<div className="rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-500/15 to-emerald-500/5 p-4 space-y-3 shrink-0">
<div
className="rounded-2xl border p-4 space-y-3 shrink-0"
style={{
borderColor: 'var(--selected-border)',
background: 'var(--selected-bg)',
}}
>
<div>
<div className="text-xs text-emerald-200/80"> 주간 수익</div>
<div className="text-xs" style={{ color: 'var(--accent-bright)' }}> 주간 수익</div>
<div ref={totalContainerRef} className="mt-1 overflow-hidden">
<div
ref={totalTextRef}
className="font-bold text-emerald-300 leading-tight whitespace-nowrap inline-block"
className="font-bold leading-tight whitespace-nowrap inline-block"
style={{ color: 'var(--accent-bright)' }}
>
{totalText}
</div>
@ -233,25 +275,42 @@ export default function CharacterPanel({
<div className="grid grid-cols-[1fr_auto] gap-x-3 items-center">
<div className="space-y-2">
<div className="text-sm text-gray-400"> 결정 개수</div>
<div className="h-2 rounded-full bg-gray-900 overflow-hidden">
<div className="text-sm" style={{ color: 'var(--text-muted)' }}> 결정 개수</div>
<div
className="h-2 rounded-full overflow-hidden"
style={{ background: 'var(--progress-track)' }}
>
<div
className={`h-full transition-all ${totalCount > MAX_PER_ACCOUNT ? 'bg-amber-500' : 'bg-emerald-500'}`}
style={{ width: `${usagePct}%` }}
className="h-full transition-all"
style={{
width: `${usagePct}%`,
background: totalCount > MAX_PER_ACCOUNT ? 'var(--progress-amber)' : 'var(--progress-emerald)',
}}
/>
</div>
</div>
<div className="flex items-baseline gap-1 tabular-nums">
<span className={`text-2xl font-bold leading-none ${totalCount > MAX_PER_ACCOUNT ? 'text-red-400' : 'text-amber-300'}`}>
<span
className="text-2xl font-bold leading-none"
style={{ color: totalCount > MAX_PER_ACCOUNT ? 'var(--danger-text)' : 'var(--warning-text-bright)' }}
>
{accountUsage}
</span>
<span className={`text-2xl font-bold leading-none ${totalCount > MAX_PER_ACCOUNT ? 'text-red-400/40' : 'text-amber-300/40'}`}>
<span
className="text-2xl font-bold leading-none"
style={{
color: totalCount > MAX_PER_ACCOUNT ? 'var(--danger-text)' : 'var(--warning-text-dim)',
opacity: totalCount > MAX_PER_ACCOUNT ? 0.4 : 1,
}}
>
/ {MAX_PER_ACCOUNT}
</span>
</div>
</div>
{totalCount > MAX_PER_ACCOUNT && (
<p className="text-[10px] text-amber-400"> 한도 {totalCount - MAX_PER_ACCOUNT} 초과</p>
<p className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
한도 {totalCount - MAX_PER_ACCOUNT} 초과
</p>
)}
</div>
@ -259,7 +318,10 @@ export default function CharacterPanel({
<div className="shrink-0">
<form onSubmit={handleSubmit} className="flex gap-2">
<div className="relative flex-1 min-w-0">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: 'var(--input-icon)' }}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M10 10L14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
@ -270,42 +332,62 @@ export default function CharacterPanel({
value={name}
onChange={(e) => { setName(e.target.value); if (error) setError('') }}
placeholder="캐릭터 닉네임 검색"
className="w-full rounded-lg border-2 border-white/10 bg-gray-950 pl-10 pr-3 py-2.5 text-sm outline-none focus:border-emerald-500/60 hover:border-white/20 transition"
className="w-full rounded-lg border-2 pl-10 pr-3 py-2.5 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
style={{
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}}
/>
</div>
<button
type="submit"
disabled={searchMutation.isPending}
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-5 py-2.5 text-sm font-medium transition shrink-0 shadow-lg shadow-emerald-500/20"
className="rounded-lg disabled:opacity-50 px-5 py-2.5 text-sm font-medium shrink-0 hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
{searchMutation.isPending ? '...' : '추가'}
</button>
</form>
{error && <p className="text-xs text-red-400 mt-1.5">{error}</p>}
{error && (
<p className="text-xs mt-1.5" style={{ color: 'var(--danger-text)' }}>{error}</p>
)}
</div>
{/* 캐릭터 목록 (스크롤) */}
{characters.length > 0 && (
<div className="flex-1 min-h-0 overflow-y-auto -mx-4 px-4">
<Reorder.Group
axis="y"
values={characters}
onReorder={onReorder}
className="space-y-2"
>
{characters.map((char) => (
<CharacterItem
key={char.character_name}
char={char}
isSelected={selectedName === char.character_name}
selections={allSelections[char.character_name] || {}}
bosses={bosses}
onSelect={onSelect}
onRemove={setConfirmRemove}
/>
))}
</Reorder.Group>
</div>
<OverlayScrollbarsComponent
className="flex-1 min-h-0 -mx-4"
options={{
scrollbars: { theme: 'os-theme-maple os-theme-dark', autoHide: 'leave', autoHideDelay: 800 },
}}
defer
>
<div className="px-4">
<Reorder.Group
axis="y"
values={characters}
onReorder={onReorder}
className="space-y-2"
>
{characters.map((char) => (
<CharacterItem
key={char.character_name}
char={char}
isSelected={selectedName === char.character_name}
selections={allSelections[char.character_name] || {}}
bosses={bosses}
onSelect={onSelect}
onRemove={setConfirmRemove}
/>
))}
</Reorder.Group>
</div>
</OverlayScrollbarsComponent>
)}
<ConfirmDialog

View file

@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { api } from '../../api/client'
@ -8,11 +8,10 @@ import {
WEEKLY_BOSSES,
MONTHLY_BOSSES,
calcPoints,
addWeeks,
formatDate,
todayKST,
} from './data'
import WeekCard from './components/WeekCard'
import { useLiberationStore } from './store'
import QuestSelector from './components/QuestSelector'
import PointsInput from './components/PointsInput'
import ProgressBar from './components/ProgressBar'
@ -21,15 +20,6 @@ import DatePicker from '../../components/DatePicker'
import ConfirmDialog from '../../components/ConfirmDialog'
import { useLayout } from '../../components/Layout'
const STORAGE_KEY = 'maple-liberation'
function makeEmptyWeek(startDate) {
return {
startDate: dayjs(startDate).toISOString(),
...makeEmptyWeekly(),
}
}
function makeEmptyWeekly() {
const bosses = {}
WEEKLY_BOSSES.forEach((b) => {
@ -69,9 +59,6 @@ function calcMonthlyEarn(weekData) {
return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage)
}
function calcMonthlyDoneEarn(weekData) {
return weekData.blackMage?.done ? bossEarn(MONTHLY_BOSSES[0], weekData.blackMage) : 0
}
export default function Liberation() {
const { setFullscreen } = useLayout()
@ -93,83 +80,12 @@ export default function Liberation() {
staleTime: Infinity,
})
const [state, setState] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const parsed = JSON.parse(saved)
if (!parsed.weekly) parsed.weekly = makeEmptyWeekly()
if (!parsed.startDate) parsed.startDate = dayjs(todayKST()).toISOString()
if (!parsed.weekOverrides) parsed.weekOverrides = {}
// enabled/'none'
const migrate = (sel, defaultDiff) => {
if (!sel) return sel
if (!sel.difficulty || sel.difficulty === 'none') sel.difficulty = defaultDiff
delete sel.enabled
return sel
}
WEEKLY_BOSSES.forEach((b) => {
if (parsed.weekly.bosses?.[b.key]) {
parsed.weekly.bosses[b.key] = migrate(parsed.weekly.bosses[b.key], b.difficulties[0].key)
}
})
parsed.weekly.blackMage = migrate(parsed.weekly.blackMage, MONTHLY_BOSSES[0].difficulties[0].key)
return parsed
} catch { /* ignore */ }
}
return {
startChapter: 0,
currentPoints: 0,
startDate: dayjs(todayKST()).toISOString(),
weekly: makeEmptyWeekly(),
weekOverrides: {},
weeks: [makeEmptyWeek(todayKST())],
}
})
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}, [state])
//
const progressByWeek = useMemo(() => {
const result = []
const startConsumedBefore = GENESIS_CHAPTERS
.slice(0, state.startChapter)
.reduce((s, c) => s + c.required, 0)
const currentChapterCap = GENESIS_CHAPTERS[state.startChapter]?.required ?? 0
const clampedCurrent = Math.min(state.currentPoints, currentChapterCap)
let totalAccumulated = startConsumedBefore + clampedCurrent
for (const week of state.weeks) {
const earned = calcWeekPoints(week)
totalAccumulated += earned
let temp = totalAccumulated
let chapterIdx = 0
while (chapterIdx < GENESIS_CHAPTERS.length && temp >= GENESIS_CHAPTERS[chapterIdx].required) {
temp -= GENESIS_CHAPTERS[chapterIdx].required
chapterIdx++
}
const isCompleted = totalAccumulated >= GENESIS_TOTAL
const chapterInfo = isCompleted
? { name: '완료', current: GENESIS_TOTAL, required: GENESIS_TOTAL }
: {
name: GENESIS_CHAPTERS[chapterIdx]?.boss || '',
current: temp,
required: GENESIS_CHAPTERS[chapterIdx]?.required || 0,
}
result.push({
points: earned,
cumulative: totalAccumulated,
completed: isCompleted,
chapterInfo,
})
}
return result
}, [state])
const calcMode = useLiberationStore((s) => s.calcMode)
const state = useLiberationStore((s) => s[s.calcMode])
const setCalcMode = useLiberationStore((s) => s.setCalcMode)
const updateSlot = useLiberationStore((s) => s.updateSlot)
const resetSlot = useLiberationStore((s) => s.resetSlot)
const setState = (updater) => updateSlot(updater)
// : required
const priorConsumed = GENESIS_CHAPTERS
@ -192,35 +108,137 @@ export default function Liberation() {
const monthlyEarn = calcMonthlyEarn(state.weekly)
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
// ( 1 )
const headerWeekly = calcMode === 'weekly'
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
: weeklyEarn
const headerMonthly = (() => {
if (calcMode !== 'weekly') return monthlyEarn
const sw = state.schedulerWeeks || []
if (!state.startDate) return 0
const claimed = {}
sw.forEach((w, idx) => {
const diff = w.config.blackMage?.difficulty
if (!diff || diff === 'none') return
const r = (() => {
const start = dayjs(state.startDate).tz('Asia/Seoul').startOf('day')
const dow = start.day()
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
const nextThu = start.add(daysToNextThu, 'day')
if (idx + 1 === 1) return { start, end: nextThu.subtract(1, 'day') }
const ws = nextThu.add((idx + 1 - 2) * 7, 'day')
return { start: ws, end: ws.add(6, 'day') }
})()
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
for (const m of months) {
if (!(m in claimed)) {
claimed[m] = bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
return
}
}
})
return Object.values(claimed).reduce((s, v) => s + v, 0)
})()
//
function computeCompletionDate() {
if (alreadyDone) return todayKST()
if (weeklyEarn === 0 && monthlyEarn === 0) return null
if (remaining <= 0) return dayjs(state.startDate).tz('Asia/Seoul').startOf('day').toDate()
const startKST = dayjs(state.startDate).tz('Asia/Seoul').startOf('day')
const events = []
// : ( - ) + ( , )
const day0Weekly = Math.max(weeklyEarn - doneEarn, 0)
const day0Monthly = monthlyEarn > 0 && !monthlyDoneThisMonth ? monthlyEarn : 0
events.push({ date: startKST, amount: day0Weekly + day0Monthly })
if (calcMode === 'weekly') {
// :
const sw = state.schedulerWeeks || []
if (sw.length === 0) return null
// : = 1 , = 2/3
const dow = startKST.day()
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
// 1: ( - done)
const week1Cfg = sw[0]?.config || makeEmptyWeekly()
const w1Weekly = calcWeekPoints(week1Cfg)
const w1Done = calcDoneEarn(week1Cfg)
events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) })
// 2 :
//
let nextThu = startKST.add(daysToNextThu, 'day')
for (let i = 1; i < 520; i++) {
const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyWeekly()
events.push({ date: nextThu, amount: calcWeekPoints(cfg) })
nextThu = nextThu.add(1, 'week')
}
//
const dow = startKST.day()
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
let nextThu = startKST.add(daysToNextThu, 'day')
for (let i = 0; i < 520; i++) {
events.push({ date: nextThu, amount: weeklyEarn })
nextThu = nextThu.add(1, 'week')
}
// : (or 1 )
const claimed = {} // monthKey -> { weekIdx, earn, doneAlready }
sw.forEach((w, i) => {
const diff = w.config.blackMage?.difficulty
if (!diff || diff === 'none') return
const range = getSchedulerWeekRange(state.startDate, i + 1)
const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')]
for (const m of months) {
if (!(m in claimed)) {
claimed[m] = {
weekIdx: i,
earn: bossEarn(MONTHLY_BOSSES[0], w.config.blackMage),
done: !!w.config.blackMage.done,
}
return
}
}
})
Object.entries(claimed).forEach(([, info]) => {
if (info.done) return
const wIdx = info.weekIdx
// 1 ,
const date = wIdx === 0
? startKST
: startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day')
events.push({ date, amount: info.earn })
})
// 1
if (monthlyEarn > 0) {
let nextMonth = startKST.add(1, 'month').startOf('month')
for (let i = 0; i < 120; i++) {
events.push({ date: nextMonth, amount: monthlyEarn })
nextMonth = nextMonth.add(1, 'month')
//
const lastCfg = sw[sw.length - 1]?.config
const lastBmEarn = lastCfg ? bossEarn(MONTHLY_BOSSES[0], lastCfg.blackMage) : 0
if (lastBmEarn > 0) {
const lastWeekStart = sw.length === 1
? startKST
: startKST.add(daysToNextThu + (sw.length - 2) * 7, 'day')
const claimedMonths = new Set(Object.keys(claimed))
let cursor = lastWeekStart.add(1, 'month').startOf('month')
for (let i = 0; i < 120; i++) {
const m = cursor.format('YYYY-MM')
if (!claimedMonths.has(m)) {
events.push({ date: cursor, amount: lastBmEarn })
}
cursor = cursor.add(1, 'month')
}
}
} else {
// :
if (weeklyEarn === 0 && monthlyEarn === 0) return null
// : ( - ) + ( , )
const day0Weekly = Math.max(weeklyEarn - doneEarn, 0)
const day0Monthly = monthlyEarn > 0 && !monthlyDoneThisMonth ? monthlyEarn : 0
events.push({ date: startKST, amount: day0Weekly + day0Monthly })
//
const dow = startKST.day()
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
let nextThu = startKST.add(daysToNextThu, 'day')
for (let i = 0; i < 520; i++) {
events.push({ date: nextThu, amount: weeklyEarn })
nextThu = nextThu.add(1, 'week')
}
// 1
if (monthlyEarn > 0) {
let nextMonth = startKST.add(1, 'month').startOf('month')
for (let i = 0; i < 120; i++) {
events.push({ date: nextMonth, amount: monthlyEarn })
nextMonth = nextMonth.add(1, 'month')
}
}
}
@ -233,88 +251,104 @@ export default function Liberation() {
return null
}
function getSchedulerWeekRange(startDateStr, weekIdx) {
const start = dayjs(startDateStr).tz('Asia/Seoul').startOf('day')
const dow = start.day()
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
const nextThu = start.add(daysToNextThu, 'day')
if (weekIdx === 1) return { start, end: nextThu.subtract(1, 'day') }
const ws = nextThu.add((weekIdx - 2) * 7, 'day')
return { start: ws, end: ws.add(6, 'day') }
}
const completionDate = computeCompletionDate()
const isDone = completionDate !== null
const updateWeek = (idx, newWeekData) => {
setState((prev) => ({
...prev,
weeks: prev.weeks.map((w, i) => (i === idx ? newWeekData : w)),
}))
}
const addWeek = () => {
setState((prev) => {
const lastWeek = prev.weeks[prev.weeks.length - 1]
const nextStart = addWeeks(lastWeek.startDate, 1)
return {
...prev,
weeks: [...prev.weeks, { ...lastWeek, startDate: dayjs(nextStart).toISOString() }],
}
})
}
const removeWeek = (idx) => {
setState((prev) => ({ ...prev, weeks: prev.weeks.filter((_, i) => i !== idx) }))
}
const [resetOpen, setResetOpen] = useState(false)
const doReset = () => {
setState({
startChapter: 0,
currentPoints: 0,
startDate: dayjs(todayKST()).toISOString(),
weekly: makeEmptyWeekly(),
weekOverrides: {},
weeks: [makeEmptyWeek(todayKST())],
})
resetSlot()
setResetOpen(false)
}
const setFirstWeekDate = (dateStr) => {
setState((prev) => {
const weeks = prev.weeks.map((w, i) => ({
...w,
startDate: dayjs(addWeeks(dateStr, i)).toISOString(),
}))
return { ...prev, weeks }
})
}
const totalCumulative = progressByWeek[progressByWeek.length - 1]?.cumulative
|| (GENESIS_CHAPTERS.slice(0, state.startChapter).reduce((s, c) => s + c.required, 0) + state.currentPoints)
const overallProgress = Math.min((totalCumulative / GENESIS_TOTAL) * 100, 100)
return (
<div className="space-y-6 pb-10">
{/* 해방 종류 탭 */}
<div className="max-w-2xl mx-auto flex gap-2">
<div className="max-w-3xl mx-auto flex gap-2">
{[
{ key: 'genesis', label: '제네시스 해방', img: genesisImg.data?.url },
{ key: 'destiny', label: '데스티니 해방', img: destinyImg.data?.url },
].map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setLiberationType(tab.key)}
className={`flex-1 flex items-center justify-center gap-3 rounded-2xl border px-5 py-3 transition ${
liberationType === tab.key
? 'border-emerald-500/50 bg-emerald-500/10 text-emerald-200 shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-gray-900/40 text-gray-400 hover:border-white/20 hover:text-gray-200'
}`}
>
{tab.img && <img src={tab.img} alt="" className="w-8 h-8 object-contain" />}
<span className="text-base font-semibold">{tab.label}</span>
</button>
))}
].map((tab) => {
const active = liberationType === tab.key
return (
<button
key={tab.key}
type="button"
onClick={() => setLiberationType(tab.key)}
className="flex-1 flex items-center justify-center gap-3 rounded-2xl border px-5 py-3"
style={active ? {
background: 'var(--selected-bg)',
borderColor: 'var(--selected-border)',
color: 'var(--accent-bright)',
boxShadow: 'var(--btn-primary-shadow)',
} : {
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
color: 'var(--text-muted)',
}}
>
{tab.img && <img src={tab.img} alt="" className="w-8 h-8 object-contain" />}
<span className="text-base font-semibold">{tab.label}</span>
</button>
)
})}
</div>
{liberationType === 'destiny' ? (
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-16 text-center space-y-3 flex flex-col items-center justify-center" style={{ minHeight: 'calc(100vh - 220px)' }}>
<div className="text-2xl font-bold text-gray-300">구현 예정</div>
<div className="text-sm text-gray-500">데스티니 해방 계산기는 준비 중입니다.</div>
<div
className="max-w-3xl mx-auto rounded-2xl border p-16 text-center space-y-3 flex flex-col items-center justify-center"
style={{
minHeight: 'calc(100vh - 220px)',
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div className="text-2xl font-bold" style={{ color: 'var(--text-emphasis)' }}>구현 예정</div>
<div className="text-sm" style={{ color: 'var(--text-dim)' }}>데스티니 해방 계산기는 준비 중입니다.</div>
</div>
) : (<>
{/* 계산 모드 탭 */}
<div
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
style={{
background: 'var(--surface-3)',
borderColor: 'var(--panel-border)',
}}
>
{[
{ key: 'simple', label: '단순 계산' },
{ key: 'weekly', label: '주차별 계산' },
].map((t) => {
const active = calcMode === t.key
return (
<button
key={t.key}
type="button"
onClick={() => setCalcMode(t.key)}
className="flex-1 h-10 rounded-lg text-sm font-semibold"
style={active ? {
background: 'var(--selected-bg)',
color: 'var(--accent-bright)',
} : {
color: 'var(--text-muted)',
}}
>
{t.label}
</button>
)
})}
</div>
<ProgressBar
startChapter={state.startChapter}
currentPoints={state.currentPoints}
@ -322,12 +356,19 @@ export default function Liberation() {
/>
{/* 현재 진행 상태 입력 */}
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
<div className="text-lg font-semibold text-emerald-300">현재 진행 상태</div>
<div
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
<div className="grid gap-3" style={{ gridTemplateColumns: '1.2fr 1.2fr 0.7fr' }}>
<div className="grid gap-3 grid-cols-3">
<div className="space-y-1.5">
<label className="block text-xs text-gray-400">시작 날짜</label>
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
<DatePicker
value={formatDate(state.startDate)}
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
@ -335,7 +376,7 @@ export default function Liberation() {
</div>
<div className="space-y-1.5">
<label className="block text-xs text-gray-400">진행 중인 퀘스트</label>
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
<QuestSelector
value={state.startChapter}
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
@ -343,13 +384,31 @@ export default function Liberation() {
</div>
<div className="space-y-1.5">
<label className="block text-xs text-gray-400">현재 흔적</label>
<PointsInput
value={state.currentPoints}
max={3000}
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
className="w-full h-12 rounded-lg border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 transition"
/>
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 흔적</label>
<div
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
style={{
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
}}
>
<PointsInput
value={state.currentPoints}
max={3000}
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
style={{ color: 'var(--text-strong)' }}
/>
<span
className="flex items-center px-3 text-base border-l select-none tabular-nums"
style={{
borderColor: 'var(--input-border)',
color: 'var(--text-dim)',
}}
>
/ {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
@ -357,15 +416,25 @@ export default function Liberation() {
<WeeklyDefault
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={weeklyEarn}
totalMonthly={monthlyEarn}
totalWeekly={headerWeekly}
totalMonthly={headerMonthly}
remaining={remaining}
mode={calcMode}
startDate={state.startDate}
weeks={state.schedulerWeeks}
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
/>
<div className="max-w-2xl mx-auto flex justify-end">
<div className="max-w-3xl mx-auto flex justify-end">
<button
type="button"
onClick={() => setResetOpen(true)}
className="inline-flex items-center gap-2 rounded-lg border border-red-500/50 bg-red-500/10 hover:bg-red-500/20 text-red-300 hover:text-red-200 px-5 py-2.5 text-sm font-semibold transition shadow-lg shadow-red-500/10"
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
style={{
borderColor: 'var(--icon-danger-border)',
background: 'var(--icon-danger-bg)',
color: 'var(--danger-text)',
}}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
@ -380,7 +449,7 @@ export default function Liberation() {
onClose={() => setResetOpen(false)}
onConfirm={doReset}
title="전체 초기화"
description={'입력한 내용을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.'}
description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
confirmText="초기화"
destructive
/>

View file

@ -1,8 +1,10 @@
import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../data'
const DOW = ['일', '월', '화', '수', '목', '금', '토']
function formatKoreanDate(s) {
const [y, m, d] = s.split('-')
return `${y}${m}${d}`
const dow = DOW[new Date(`${s}T00:00:00+09:00`).getDay()]
return `${y}${m}${d}일 (${dow})`
}
export default function ProgressBar({ startChapter, currentPoints, completionDate }) {
@ -17,9 +19,13 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
const renderSegment = ({ chapter, status, current }) => {
const pct = (current / chapter.required) * 100
const bg = status === 'done' ? '#10b981' : status === 'active' ? '#fbbf24' : 'transparent'
const bg = status === 'done' ? 'var(--progress-emerald)' : status === 'active' ? 'var(--progress-amber)' : 'transparent'
return (
<div key={`seg-${chapter.idx}`} className="flex-1 h-2 rounded bg-gray-900 overflow-hidden">
<div
key={`seg-${chapter.idx}`}
className="flex-1 h-2 rounded overflow-hidden"
style={{ background: 'var(--progress-track)' }}
>
<div
className="h-full transition-all"
style={{ width: `${pct}%`, background: bg }}
@ -29,60 +35,75 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
}
const renderPortrait = ({ chapter, status }) => (
<div key={`p-${chapter.idx}`} className="flex-1 flex flex-col items-center gap-1.5">
<div className={`w-14 h-14 rounded-lg overflow-hidden border transition ${
status === 'done' ? 'border-emerald-500/40' :
status === 'active' ? 'border-amber-400/60 shadow-lg shadow-amber-500/20' :
'border-white/5 opacity-50'
<div key={`p-${chapter.idx}`} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
<div className={`w-full aspect-square rounded-lg overflow-hidden ${
status === 'active' ? 'shadow-lg shadow-amber-500/20' :
status === 'pending' ? 'opacity-50' : ''
}`}>
<img
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.png`}
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.webp`}
alt={chapter.boss}
className={`w-full h-full object-cover ${status === 'pending' ? 'grayscale' : ''}`}
className={`block w-full h-full object-cover ${status === 'pending' ? 'grayscale' : ''}`}
/>
</div>
<div className={`text-sm font-medium ${
status === 'done' ? 'text-emerald-300' :
status === 'active' ? 'text-amber-300' : 'text-gray-500'
}`}>
<div
className="text-sm font-medium"
style={{
color: status === 'done' ? 'var(--accent-bright)' :
status === 'active' ? 'var(--warning-text-bright)' : 'var(--text-dim)',
}}
>
{chapter.boss}
</div>
</div>
)
return (
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-5">
<div
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-5"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
{/* 섹션 제목 */}
<div className="text-lg font-semibold text-emerald-300">퀘스트 진행 상황</div>
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>퀘스트 진행 상황</div>
{/* 1차 / 2차 라벨 + 세그먼트 바 */}
<div className="space-y-3">
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
<div className="flex-1 flex flex-col items-center gap-2">
<span className="text-base font-bold" style={{ color: '#5eead4' }}>1 해방</span>
<div style={{ width: '100%', height: 3, background: 'rgba(94, 234, 212, 0.5)', borderRadius: 999 }} />
<span className="text-base font-bold" style={{ color: 'var(--liberation-primary)' }}>1 해방</span>
<div style={{ width: '100%', height: 3, background: 'var(--liberation-primary-bar)', borderRadius: 999 }} />
</div>
<div className="flex-1 flex flex-col items-center gap-2">
<span className="text-base font-bold" style={{ color: '#fda4af' }}>2 해방</span>
<div style={{ width: '100%', height: 3, background: 'rgba(253, 164, 175, 0.5)', borderRadius: 999 }} />
<span className="text-base font-bold" style={{ color: 'var(--liberation-secondary)' }}>2 해방</span>
<div style={{ width: '100%', height: 3, background: 'var(--liberation-secondary-bar)', borderRadius: 999 }} />
</div>
</div>
<div className="flex gap-1">
<div className="flex gap-2">
{chapterStates.map(renderSegment)}
</div>
</div>
{/* 초상화 (붙어있음) */}
<div className="flex gap-1">
<div className="flex gap-2">
{chapterStates.map(renderPortrait)}
</div>
{/* 예상 해방 날짜 */}
<div className="flex items-center justify-center gap-2 pt-4 border-t border-white/5 text-base">
<span className="text-emerald-300/80">예상 해방 날짜</span>
<span className="text-gray-600">·</span>
<span className="font-semibold tabular-nums text-amber-400">
{completionDate ? formatKoreanDate(completionDate) : <span className="text-gray-500 font-normal">미정</span>}
<div
className="flex items-center justify-center gap-3 pt-4 border-t"
style={{ borderColor: 'var(--panel-border)' }}
>
<span className="text-lg font-semibold" style={{ color: 'var(--text-strong)' }}>예상 해방 날짜</span>
<span style={{ color: 'var(--text-dim)' }}>·</span>
<span
className="text-xl font-bold tabular-nums"
style={{ color: 'var(--warning-text-bright)' }}
>
{completionDate ? formatKoreanDate(completionDate) : <span className="font-normal" style={{ color: 'var(--text-dim)' }}>미정</span>}
</span>
</div>
</div>

View file

@ -26,23 +26,30 @@ export default function QuestSelector({ value, onChange }) {
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`w-full h-12 flex items-center gap-3 rounded-lg border bg-gray-950 pl-2 pr-3 transition ${
open ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
}`}
className="w-full h-12 flex items-center gap-3 rounded-lg border pl-2 pr-3"
style={{
background: 'var(--input-bg)',
borderColor: open ? 'var(--input-border-focus)' : 'var(--input-border)',
color: 'var(--text-strong)',
}}
>
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-900">
<div
className="w-9 h-9 rounded overflow-hidden shrink-0"
style={{ background: 'var(--surface-nested)' }}
>
<img
src={`${QUEST_BOSS_IMAGE_BASE}/${selected.boss}.png`}
src={`${QUEST_BOSS_IMAGE_BASE}/${selected.boss}.webp`}
alt=""
className="w-full h-full object-cover"
/>
</div>
<span className="flex-1 text-left text-sm font-medium text-gray-100">
<span className="flex-1 text-left text-sm font-medium">
{selected.boss}
</span>
<svg
width="14" height="14" viewBox="0 0 12 12" fill="none"
className={`text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
className={`transition-transform ${open ? 'rotate-180' : ''}`}
style={{ color: 'var(--input-icon)' }}
>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
@ -55,7 +62,12 @@ export default function QuestSelector({ value, onChange }) {
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.98 }}
transition={{ duration: 0.15 }}
className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-white/10 bg-gray-900 shadow-2xl py-1 max-h-72 overflow-y-auto origin-top"
className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border py-1 max-h-72 overflow-y-auto origin-top"
style={{
background: 'var(--popup-bg)',
borderColor: 'var(--popup-border)',
boxShadow: 'var(--popup-shadow)',
}}
>
{GENESIS_CHAPTERS.map((chapter) => {
const isSelected = chapter.idx === value
@ -64,20 +76,25 @@ export default function QuestSelector({ value, onChange }) {
key={chapter.idx}
type="button"
onClick={() => { onChange(chapter.idx); setOpen(false) }}
className={`w-full flex items-center gap-3 px-2 py-1.5 transition ${
isSelected ? 'bg-emerald-500/10' : 'hover:bg-white/5'
}`}
className="w-full flex items-center gap-3 px-2 py-1.5"
style={isSelected ? { background: 'var(--option-selected-bg)' } : undefined}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'var(--row-hover-bg)' }}
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = '' }}
>
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-950">
<div
className="w-9 h-9 rounded overflow-hidden shrink-0"
style={{ background: 'var(--surface-nested)' }}
>
<img
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.png`}
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.webp`}
alt=""
className="w-full h-full object-cover"
/>
</div>
<span className={`flex-1 text-left text-sm font-medium ${
isSelected ? 'text-emerald-300' : 'text-gray-200'
}`}>
<span
className="flex-1 text-left text-sm font-medium"
style={{ color: isSelected ? 'var(--option-selected-text)' : 'var(--text-emphasis)' }}
>
{chapter.boss}
</span>
</button>

View file

@ -1,134 +0,0 @@
import Select from '../../../components/Select'
import Checkbox from '../../../components/Checkbox'
import Tooltip from '../../../components/Tooltip'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, BOSS_IMAGE_BASE, calcPoints, formatDate } from '../data'
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}` }))
/**
* week: { startDate, bosses: { [bossKey]: { enabled, difficulty, party } }, includeBlackMage: {enabled, difficulty, party} }
*/
export default function WeekCard({ weekNumber, weekData, cumulativePoints, currentChapter, chapterInfo, onChange, weekProgress }) {
const totalThisWeek = weekProgress.points
const updateBoss = (bossKey, patch) => {
const nextBosses = { ...weekData.bosses, [bossKey]: { ...weekData.bosses[bossKey], ...patch } }
onChange({ ...weekData, bosses: nextBosses })
}
const updateBlackMage = (patch) => {
onChange({ ...weekData, blackMage: { ...weekData.blackMage, ...patch } })
}
return (
<div className="rounded-xl border border-white/5 bg-gray-900/40 overflow-hidden">
{/* 헤더: 주차 번호 + 날짜 + 이번 주 획득 + 누적 */}
<div className="flex items-center justify-between gap-3 px-4 py-3 bg-gray-950/60 border-b border-white/5">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold">{weekNumber}주차</div>
<div className="text-xs text-gray-500">{formatDate(weekData.startDate)}</div>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="text-gray-400">
획득 <span className="text-emerald-300 font-semibold tabular-nums">+{totalThisWeek}</span>
</div>
<div className="text-gray-400">
누적 <span className="text-white font-semibold tabular-nums">{cumulativePoints}</span>
</div>
{chapterInfo && (
<div className="text-xs text-gray-500">
{chapterInfo.name} {chapterInfo.current}/{chapterInfo.required}
</div>
)}
</div>
</div>
{/* 보스 그리드 */}
<div className="p-3 grid grid-cols-2 sm:grid-cols-4 gap-2">
{WEEKLY_BOSSES.map((boss) => {
const sel = weekData.bosses[boss.key] || { enabled: false, difficulty: boss.difficulties[0].key, party: 1 }
const diff = boss.difficulties.find((d) => d.key === sel.difficulty) || boss.difficulties[0]
const earned = sel.enabled ? calcPoints(diff.points, sel.party) : 0
return (
<div key={boss.key} className={`rounded-lg border p-2 transition ${sel.enabled ? 'border-white/10 bg-gray-950/40' : 'border-white/5 bg-transparent opacity-60'}`}>
<div className="flex items-center gap-2 mb-2">
<Checkbox
checked={sel.enabled}
onChange={(v) => updateBoss(boss.key, { enabled: v })}
size="sm"
/>
<Tooltip text={boss.name}>
<img src={`${BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-7 h-7 rounded object-cover" />
</Tooltip>
<span className="text-xs font-medium truncate flex-1">{boss.name}</span>
{earned > 0 && (
<span className="text-xs text-emerald-300 font-semibold tabular-nums">+{earned}</span>
)}
</div>
{sel.enabled && (
<div className="flex gap-1.5">
<Select
value={sel.difficulty}
onChange={(v) => updateBoss(boss.key, { difficulty: v })}
options={boss.difficulties.map((d) => ({ value: d.key, label: `${d.label} +${d.points}` }))}
className="flex-1"
/>
<Select
value={sel.party}
onChange={(v) => updateBoss(boss.key, { party: v })}
options={PARTY_OPTIONS}
className="w-16"
/>
</div>
)}
</div>
)
})}
{/* 검은 마법사 (월 1회) */}
{MONTHLY_BOSSES.map((boss) => {
const sel = weekData.blackMage || { enabled: false, difficulty: boss.difficulties[0].key, party: 1 }
const diff = boss.difficulties.find((d) => d.key === sel.difficulty) || boss.difficulties[0]
const earned = sel.enabled ? calcPoints(diff.points, sel.party) : 0
return (
<div key={boss.key} className={`rounded-lg border p-2 transition col-span-2 sm:col-span-2 ${
sel.enabled ? 'border-amber-500/40 bg-amber-500/[0.05]' : 'border-white/5 bg-transparent opacity-60'
}`}>
<div className="flex items-center gap-2 mb-2">
<Checkbox
checked={sel.enabled}
onChange={(v) => updateBlackMage({ enabled: v })}
size="sm"
/>
<Tooltip text={`${boss.name} (월 1회)`}>
<img src={`${BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-7 h-7 rounded object-cover" />
</Tooltip>
<span className="text-xs font-medium flex-1">{boss.name} <span className="text-[10px] text-amber-400">월간</span></span>
{earned > 0 && (
<span className="text-xs text-emerald-300 font-semibold tabular-nums">+{earned}</span>
)}
</div>
{sel.enabled && (
<div className="flex gap-1.5">
<Select
value={sel.difficulty}
onChange={(v) => updateBlackMage({ difficulty: v })}
options={boss.difficulties.map((d) => ({ value: d.key, label: `${d.label} +${d.points}` }))}
className="flex-1"
/>
<Select
value={sel.party}
onChange={(v) => updateBlackMage({ party: v })}
options={PARTY_OPTIONS}
className="w-16"
/>
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View file

@ -1,34 +1,41 @@
import { useState } from 'react'
import Select from '../../../components/Select'
import Tooltip from '../../../components/Tooltip'
import WeeklyScheduler from './WeeklyScheduler'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, calcPoints } from '../data'
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}` }))
const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 }
function diffLabel(d, party) {
if (d.key === 'none') return <span className="text-gray-500">격파 불가</span>
if (d.key === 'none') return <span style={{ color: 'var(--text-dim)' }}>격파 불가</span>
const earned = calcPoints(d.points, party)
return (
<span>
{d.label} <span className="text-emerald-400">+{earned}</span>
{d.label} <span style={{ color: 'var(--accent-bright)' }}>+{earned}</span>
</span>
)
}
function BossRow({ boss, sel, onChange, monthly = false }) {
export function BossRow({ boss, sel, onChange, monthly = false, showDone = true }) {
const disabled = sel.difficulty === 'none'
const difficultyOptions = [NONE_DIFFICULTY, ...boss.difficulties]
.map((d) => ({ value: d.key, label: diffLabel(d, sel.party) }))
return (
<div className="flex items-center gap-3 rounded-lg px-3 h-14 transition">
<div className="flex items-center gap-3 rounded-lg px-3 h-16">
<Tooltip text={boss.name}>
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt="" className="w-8 h-8 rounded object-cover shrink-0" />
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt="" className="w-10 h-10 rounded-md object-cover shrink-0" />
</Tooltip>
<span className="text-sm font-medium flex-1 truncate">
<span className="text-base font-semibold flex-1 truncate">
{boss.name}
{monthly && <span className="ml-1.5 text-[10px] text-amber-400/80">월간</span>}
{monthly && (
<span
className="ml-1.5 text-[11px] font-medium"
style={{ color: 'var(--warning-text)' }}
>
월간
</span>
)}
</span>
<div className="w-36">
@ -49,27 +56,32 @@ function BossRow({ boss, sel, onChange, monthly = false }) {
disabled={disabled}
/>
</div>
<button
type="button"
disabled={disabled}
onClick={() => onChange({ done: !sel.done })}
className={`shrink-0 w-20 rounded-md h-8 text-xs font-semibold transition border ${
disabled
? 'border-white/5 text-gray-700 cursor-not-allowed'
: sel.done
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-300'
: 'border-white/10 text-gray-500 hover:border-white/20 hover:text-gray-300'
}`}
>
{sel.done ? '완료' : '미완료'}
</button>
{showDone && (
<button
type="button"
disabled={disabled}
onClick={() => onChange({ done: !sel.done })}
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border disabled:cursor-not-allowed"
style={disabled ? {
borderColor: 'var(--panel-border)',
color: 'var(--text-dim)',
} : sel.done ? {
background: 'var(--selected-bg)',
borderColor: 'var(--selected-border)',
color: 'var(--accent-bright)',
} : {
borderColor: 'var(--btn-border)',
color: 'var(--text-dim)',
}}
>
{sel.done ? '완료' : '미완료'}
</button>
)}
</div>
)
}
export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMonthly }) {
const [mode, setMode] = useState('simple') // 'simple' | 'weekly'
export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMonthly, remaining, mode = 'simple', startDate, weeks, onChangeWeeks }) {
const updateBoss = (key, patch) => {
onChange({ ...weekly, bosses: { ...weekly.bosses, [key]: { ...weekly.bosses[key], ...patch } } })
}
@ -78,66 +90,68 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
}
return (
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
<div
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div className="flex items-center justify-between">
<div className="text-lg font-semibold text-emerald-300">주간 보스 설정</div>
<div className="inline-flex rounded-lg border border-white/10 bg-gray-950 p-0.5">
<TabButton active={mode === 'simple'} onClick={() => setMode('simple')}>단순 계산</TabButton>
<TabButton active={mode === 'weekly'} onClick={() => setMode('weekly')}>주차별 계산</TabButton>
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>주간 보스 설정</div>
<div className="text-sm tabular-nums">
{mode === 'weekly' ? (
<>
<span className="font-semibold" style={{ color: 'var(--accent-bright)' }}>{totalWeekly}</span>
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>+</span>
<span className="font-semibold" style={{ color: 'var(--warning-text-bright)' }}>{totalMonthly}</span>
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>/</span>
<span className="font-semibold" style={{ color: 'var(--text-emphasis)' }}>{(remaining ?? 0).toLocaleString()}</span>
</>
) : (
<span className="font-semibold" style={{ color: 'var(--accent-bright)' }}>+{totalWeekly + totalMonthly}</span>
)}
</div>
</div>
{mode === 'simple' ? (
<>
<div className="flex items-baseline justify-end text-sm text-gray-400 gap-3">
<span>
주간 획득 <span className="text-emerald-300 font-semibold tabular-nums">+{totalWeekly}</span>
</span>
<span>
월간 획득 <span className="text-amber-300 font-semibold tabular-nums">+{totalMonthly}</span>
</span>
</div>
<div className="divide-y divide-white/5">
{WEEKLY_BOSSES.map((boss) => (
<div>
{WEEKLY_BOSSES.map((boss, i) => (
<div
key={boss.key}
className={i > 0 ? 'border-t' : ''}
style={i > 0 ? { borderColor: 'var(--row-divider)' } : undefined}
>
<BossRow
key={boss.key}
boss={boss}
sel={weekly.bosses[boss.key]}
onChange={(patch) => updateBoss(boss.key, patch)}
/>
))}
{MONTHLY_BOSSES.map((boss) => (
</div>
))}
{MONTHLY_BOSSES.map((boss) => (
<div
key={boss.key}
className="border-t"
style={{ borderColor: 'var(--row-divider)' }}
>
<BossRow
key={boss.key}
boss={boss}
sel={weekly.blackMage}
onChange={updateBlackMage}
monthly
/>
))}
</div>
</>
) : (
<div className="py-12 text-center text-sm text-gray-500">
주차별 계산 UI 준비
</div>
))}
</div>
) : (
<WeeklyScheduler
startDate={startDate}
weeks={weeks}
onChangeWeeks={onChangeWeeks}
/>
)}
</div>
)
}
function TabButton({ active, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
className={`px-3 h-8 rounded-md text-sm font-medium transition ${
active
? 'bg-emerald-500/20 text-emerald-300'
: 'text-gray-400 hover:text-gray-200'
}`}
>
{children}
</button>
)
}

View file

@ -0,0 +1,351 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import dayjs from 'dayjs'
import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES, calcPoints } from '../data'
import { BossRow } from './WeeklyDefault'
function bossEarn(boss, sel) {
if (!sel || !sel.difficulty || sel.difficulty === 'none') return 0
const d = boss.difficulties.find((x) => x.key === sel.difficulty)
if (!d) return 0
return calcPoints(d.points, sel.party)
}
function calcWeeklySum(config) {
let sum = 0
WEEKLY_BOSSES.forEach((b) => { sum += bossEarn(b, config.bosses[b.key]) })
return sum
}
const KST = 'Asia/Seoul'
// ( , 1 )
function getWeekRange(startDateStr, weekIdx) {
const start = dayjs(startDateStr).tz(KST).startOf('day')
const dow = start.day()
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
const nextThu = start.add(daysToNextThu, 'day')
if (weekIdx === 1) {
return { start, end: nextThu.subtract(1, 'day') }
}
const weekStart = nextThu.add((weekIdx - 2) * 7, 'day')
const weekEnd = weekStart.add(6, 'day')
return { start: weekStart, end: weekEnd }
}
function formatRange(r) {
const fmt = (d) => `${d.month() + 1}/${d.date()}`
return `${fmt(r.start)} ~ ${fmt(r.end)}`
}
const DIFF_BADGE = {
easy: { label: 'E', color: '#22c55e', border: 'rgba(34,197,94,0.4)', bg: 'rgba(34,197,94,0.15)' },
normal: { label: 'N', color: '#60a5fa', border: 'rgba(96,165,250,0.4)', bg: 'rgba(96,165,250,0.15)' },
hard: { label: 'H', color: '#f87171', border: 'rgba(248,113,113,0.4)', bg: 'rgba(248,113,113,0.15)' },
chaos: { label: 'C', color: '#c084fc', border: 'rgba(192,132,252,0.45)', bg: 'rgba(192,132,252,0.15)' },
extreme: { label: 'X', color: '#f59e0b', border: 'rgba(245,158,11,0.5)', bg: 'rgba(245,158,11,0.2)' },
}
function makeEmptyWeek() {
const bosses = {}
WEEKLY_BOSSES.forEach((b) => {
bosses[b.key] = { difficulty: 'none', party: 1, done: false }
})
return {
bosses,
blackMage: { difficulty: 'none', party: 1, done: false },
}
}
function BossAvatar({ boss, difficulty, size = 40 }) {
const badge = DIFF_BADGE[difficulty]
const enabled = difficulty && difficulty !== 'none'
return (
<div className="flex flex-col items-center gap-1">
<div
className={`rounded-md overflow-hidden border ${enabled ? '' : 'opacity-30 grayscale'}`}
style={{
width: size,
height: size,
background: 'var(--surface-nested)',
borderColor: 'var(--panel-border)',
}}
>
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-full h-full object-cover" />
</div>
<div
className="text-[10px] font-bold leading-none rounded flex items-center justify-center border"
style={{
width: 16, height: 16,
color: badge?.color || 'var(--text-dim)',
background: badge?.bg || 'transparent',
borderColor: badge?.border || 'var(--panel-border)',
}}
>
{badge?.label || '-'}
</div>
</div>
)
}
function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
const updateBoss = (key, patch) => {
onChange({ ...config, bosses: { ...config.bosses, [key]: { ...config.bosses[key], ...patch } } })
}
const updateBlackMage = (patch) => {
onChange({ ...config, blackMage: { ...config.blackMage, ...patch } })
}
const blackmageLocked = monthlyLockedByWeek != null
return (
<div>
{WEEKLY_BOSSES.map((boss, i) => (
<div
key={boss.key}
className={i > 0 ? 'border-t' : ''}
style={i > 0 ? { borderColor: 'var(--row-divider)' } : undefined}
>
<BossRow
boss={boss}
sel={config.bosses[boss.key]}
onChange={(patch) => updateBoss(boss.key, patch)}
showDone={isCurrent}
/>
</div>
))}
<div
className={`border-t ${blackmageLocked ? 'opacity-40 pointer-events-none' : ''}`}
style={{ borderColor: 'var(--row-divider)' }}
>
<BossRow
boss={MONTHLY_BOSSES[0]}
sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage}
onChange={updateBlackMage}
monthly
showDone={isCurrent}
/>
</div>
{blackmageLocked && (
<div
className="text-[11px] px-3 py-2"
style={{ color: 'var(--warning-text)' }}
>
이번 검은 마법사는 {monthlyLockedByWeek}주차에 배정되어 있습니다.
</div>
)}
</div>
)
}
export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) {
const weeks = weeksProp && weeksProp.length > 0
? weeksProp
: [{ id: 1, config: makeEmptyWeek() }]
const setWeeks = (updater) => {
const next = typeof updater === 'function' ? updater(weeks) : updater
onChangeWeeks?.(next)
}
const [expanded, setExpanded] = useState(null)
const nextId = () => (weeks[weeks.length - 1]?.id ?? 0) + 1
const addWeek = () => {
const id = nextId()
setWeeks((prev) => {
const last = prev[prev.length - 1]
const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeek()
// done
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
if (base.blackMage) base.blackMage.done = false
//
if (startDate && base.blackMage?.difficulty && base.blackMage.difficulty !== 'none') {
const newIdx = prev.length + 1
const newMonth = getWeekRange(startDate, newIdx).start.format('YYYY-MM')
const existsInSameMonth = prev.some((p, i) => {
if (!p.config.blackMage?.difficulty || p.config.blackMage.difficulty === 'none') return false
return getWeekRange(startDate, i + 1).start.format('YYYY-MM') === newMonth
})
if (existsInSameMonth) {
base.blackMage = { difficulty: 'none', party: 1, done: false }
}
}
return [...prev, { id, config: base }]
})
setExpanded(id)
}
const removeWeek = (id) => {
setWeeks((prev) => prev.filter((w) => w.id !== id))
if (expanded === id) setExpanded(null)
}
const updateWeek = (id, config) => {
setWeeks((prev) => prev.map((w) => (w.id === id ? { ...w, config } : w)))
}
// :
const monthlyLocks = (() => {
if (!startDate) return {}
const claimed = {} // month -> weekNum (1-based)
weeks.forEach((w, idx) => {
const diff = w.config.blackMage?.difficulty
if (!diff || diff === 'none') return
const r = getWeekRange(startDate, idx + 1)
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
for (const m of months) {
if (!(m in claimed)) {
claimed[m] = idx + 1
return
}
}
})
const locks = {}
weeks.forEach((w, idx) => {
const r = getWeekRange(startDate, idx + 1)
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
//
if (months.some((m) => claimed[m] === idx + 1)) return
//
if (months.every((m) => m in claimed)) {
locks[idx] = claimed[months[0]] ?? claimed[months[1]]
}
})
return locks
})()
return (
<div className="space-y-2">
{weeks.map((w, idx) => {
const n = idx + 1
const isOpen = expanded === w.id
const isCurrent = idx === 0 // : ( )
// monthlyLocks
const monthlyLockedByWeek = monthlyLocks[idx] ?? null
return (
<div
key={w.id}
className="rounded-xl border"
style={{
background: 'var(--surface-3)',
borderColor: 'var(--panel-border)',
}}
>
<div className="flex items-center gap-3 pl-4 pr-2 py-3">
<button
type="button"
onClick={() => setExpanded(isOpen ? null : w.id)}
className="flex items-center gap-4 flex-1 text-left hover:opacity-90 transition"
>
<div className="w-12 text-center shrink-0">
<div className="text-[11px] leading-tight" style={{ color: 'var(--text-dim)' }}>주차</div>
<div
className="text-xl font-extrabold tabular-nums leading-tight"
style={{ color: 'var(--text-emphasis)' }}
>
{n}
</div>
</div>
{startDate && (
<div
className="text-sm tabular-nums w-24 shrink-0"
style={{ color: 'var(--text-muted)' }}
>
{formatRange(getWeekRange(startDate, n))}
</div>
)}
<div className="flex-1 flex items-center gap-2">
{WEEKLY_BOSSES.map((b) => (
<BossAvatar key={b.key} boss={b} difficulty={w.config.bosses[b.key]?.difficulty} size={40} />
))}
<BossAvatar boss={MONTHLY_BOSSES[0]} difficulty={monthlyLockedByWeek != null ? 'none' : w.config.blackMage?.difficulty} size={40} />
</div>
{(() => {
const weeklySum = calcWeeklySum(w.config)
const monthlySum = monthlyLockedByWeek != null ? 0 : bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
return (
<div className="text-right shrink-0 pr-1 tabular-nums leading-tight">
<div className="text-base font-bold" style={{ color: 'var(--accent-bright)' }}>+{weeklySum}</div>
{monthlySum > 0 && (
<div className="text-sm font-semibold" style={{ color: 'var(--warning-text-bright)' }}>+{monthlySum}</div>
)}
</div>
)
})()}
<svg
width="16" height="16" viewBox="0 0 12 12" fill="none"
className={`transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--text-dim)' }}
>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
onClick={() => removeWeek(w.id)}
disabled={weeks.length <= 1}
title={weeks.length <= 1 ? '최소 한 주차는 유지되어야 합니다' : '이 주차 삭제'}
className="shrink-0 w-8 h-8 rounded-md hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed flex items-center justify-center"
style={{ color: 'var(--text-dim)' }}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 1L13 13M13 1L1 13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key="editor"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { duration: 0.35, ease: [0.22, 1, 0.36, 1] },
opacity: { duration: 0.25, ease: [0.22, 1, 0.36, 1] },
}}
style={{ overflow: 'hidden' }}
>
<div
className="border-t px-3 py-3"
style={{
borderColor: 'var(--row-divider)',
background: 'var(--skeleton-bg)',
}}
>
<WeekEditor
config={w.config}
onChange={(c) => updateWeek(w.id, c)}
isCurrent={isCurrent}
monthlyLockedByWeek={monthlyLockedByWeek}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
<button
type="button"
onClick={addWeek}
className="w-full rounded-xl border border-dashed py-3 text-sm font-semibold flex items-center justify-center gap-2 hover:border-[var(--selected-border)] hover:text-[var(--accent-bright)]"
style={{
borderColor: 'var(--dashed-border)',
color: 'var(--text-dim)',
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 1V13M1 7H13" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
주차 추가
</button>
</div>
)
}

View file

@ -13,7 +13,7 @@ export const GENESIS_CHAPTERS = [
]
// 퀘스트 이미지 경로 (제네시스)
export const QUEST_BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/quest/boss'
export const QUEST_BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/quest'
export const QUEST_BTBOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/quest/btboss'
// 주간/월간 보스 초상화 (해방용)
export const LIBERATION_BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/boss'
@ -23,7 +23,7 @@ export const GENESIS_TOTAL = GENESIS_CHAPTERS.reduce((s, c) => s + c.required, 0
// 주간 보스 (주 1회)
export const WEEKLY_BOSSES = [
{
key: 'lotus', name: '스우', image: '스우.png',
key: 'lotus', name: '스우', image: '스우.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 10 },
{ key: 'hard', label: '하드', points: 50 },
@ -31,14 +31,14 @@ export const WEEKLY_BOSSES = [
],
},
{
key: 'damien', name: '데미안', image: '데미안.png',
key: 'damien', name: '데미안', image: '데미안.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 10 },
{ key: 'hard', label: '하드', points: 50 },
],
},
{
key: 'lucid', name: '루시드', image: '루시드.png',
key: 'lucid', name: '루시드', image: '루시드.webp',
difficulties: [
{ key: 'easy', label: '이지', points: 15 },
{ key: 'normal', label: '노말', points: 20 },
@ -46,7 +46,7 @@ export const WEEKLY_BOSSES = [
],
},
{
key: 'will', name: '윌', image: '윌.png',
key: 'will', name: '윌', image: '윌.webp',
difficulties: [
{ key: 'easy', label: '이지', points: 15 },
{ key: 'normal', label: '노말', points: 25 },
@ -54,21 +54,21 @@ export const WEEKLY_BOSSES = [
],
},
{
key: 'dusk', name: '더스크', image: '더스크.png',
key: 'dusk', name: '더스크', image: '더스크.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 20 },
{ key: 'chaos', label: '카오스', points: 65 },
],
},
{
key: 'jinhilla', name: '진 힐라', image: '진 힐라.png',
key: 'jinhilla', name: '진 힐라', image: '진 힐라.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 45 },
{ key: 'hard', label: '하드', points: 90 },
],
},
{
key: 'darknell', name: '듄켈', image: '듄켈.png',
key: 'darknell', name: '듄켈', image: '듄켈.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 25 },
{ key: 'hard', label: '하드', points: 75 },
@ -79,7 +79,7 @@ export const WEEKLY_BOSSES = [
// 월간 보스
export const MONTHLY_BOSSES = [
{
key: 'blackmage', name: '검은 마법사', image: '검은 마법사.png',
key: 'blackmage', name: '검은 마법사', image: '검은 마법사.webp',
difficulties: [
{ key: 'hard', label: '하드', points: 600 },
{ key: 'extreme', label: '익스트림', points: 600 },
@ -95,15 +95,6 @@ export function calcPoints(basePoints, partySize) {
return Math.floor(basePoints / partySize)
}
// 목요일 기준 주차 계산 (KST)
// 이번 주 목요일 자정 = 이번 주의 시작
export function getThursdayOfWeek(date) {
const d = dayjs(date).tz(KST)
const day = d.day() // 0=일, 4=목
const diff = (day - 4 + 7) % 7
return d.subtract(diff, 'day').startOf('day').toDate()
}
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
@ -117,10 +108,6 @@ export function formatDate(date) {
return dayjs(date).tz(KST).format('YYYY-MM-DD')
}
export function addWeeks(date, weeks) {
return dayjs(date).tz(KST).add(weeks, 'week').toDate()
}
export function todayKST() {
return dayjs().tz(KST).startOf('day').toDate()
}

View file

@ -0,0 +1,51 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import dayjs from 'dayjs'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, todayKST } from './data'
function makeEmptyWeekly() {
const bosses = {}
WEEKLY_BOSSES.forEach((b) => {
bosses[b.key] = { difficulty: 'none', party: 1, done: false }
})
return {
bosses,
blackMage: { difficulty: 'none', party: 1, done: false },
}
}
function makeInitialSlot() {
return {
startChapter: 0,
currentPoints: 0,
startDate: dayjs(todayKST()).toISOString(),
weekly: makeEmptyWeekly(),
schedulerWeeks: [{ id: 1, config: makeEmptyWeekly() }],
}
}
/**
* 해방 계산기 상태
* calcMode: 'simple' | 'weekly'
* simple / weekly: 모드 독립 슬롯
*/
export const useLiberationStore = create(persist(
(set) => ({
calcMode: 'simple',
simple: makeInitialSlot(),
weekly: makeInitialSlot(),
setCalcMode: (mode) => set({ calcMode: mode }),
updateSlot: (patch) => set((s) => ({
[s.calcMode]: typeof patch === 'function'
? patch(s[s.calcMode])
: { ...s[s.calcMode], ...patch },
})),
resetSlot: () => set((s) => ({ [s.calcMode]: makeInitialSlot() })),
}),
{ name: 'maple-liberation' },
))
export { makeEmptyWeekly, makeInitialSlot }

View file

@ -0,0 +1,702 @@
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import { api } from '../../api/client'
import { useLayout } from '../../components/Layout'
import Select from '../../components/Select'
import Tooltip from '../../components/Tooltip'
import { useSymbolStore } from './store'
dayjs.extend(utc)
dayjs.extend(timezone)
const KST = 'Asia/Seoul'
const DOW = ['일', '월', '화', '수', '목', '금', '토']
function formatKoreanDate(d) {
const dj = dayjs(d).tz(KST)
return `${dj.year()}${String(dj.month() + 1).padStart(2, '0')}${String(dj.date()).padStart(2, '0')}일 (${DOW[dj.day()]})`
}
/**
* 심볼 완료까지 남은 일수/예상 완료일 계산
* - 일퀘는 매일, 주간퀘는 매주 목요일 리셋 N회분을 번에 지급한다고 가정
* - extra(추가 심볼) 즉시 적용
* - dailyDone이면 오늘 일퀘는 이미 받은 걸로 간주 (내일부터 다시 지급)
*/
function computeCompletion({ remainingSymbols, daily, weeklyPerWeek, extra, dailyDone }) {
const need = Math.max(remainingSymbols - extra, 0)
if (need === 0) return { days: 0, date: dayjs().tz(KST).startOf('day').toDate() }
if (daily <= 0 && weeklyPerWeek <= 0) return { days: null, date: null }
let acc = 0
let cursor = dayjs().tz(KST).startOf('day')
for (let day = 0; day < 3650; day++) {
// dailyDone , daily
if (!(day === 0 && dailyDone)) acc += daily
// (day=4)
if (cursor.day() === 4 && weeklyPerWeek > 0) acc += weeklyPerWeek
if (acc >= need) return { days: day, date: cursor.toDate() }
cursor = cursor.add(1, 'day')
}
return { days: null, date: null }
}
function formatMesoKorean(n) {
const v = Number(n) || 0
if (v <= 0) return '0'
const eok = Math.floor(v / 100_000_000)
const man = Math.floor((v % 100_000_000) / 10_000)
const parts = []
if (eok) parts.push(`${eok.toLocaleString()}`)
if (man) parts.push(`${man.toLocaleString()}`)
return parts.length ? parts.join(' ') : v.toLocaleString()
}
const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']
function CharacterCard({ char, active, onSelect, onRemove }) {
return (
<div
onClick={(e) => {
if (e.target.closest('button')) return
onSelect()
}}
className="group relative shrink-0 w-36 rounded-xl border cursor-pointer select-none"
style={{
borderColor: active ? 'var(--selected-border)' : 'var(--panel-border)',
background: active ? 'var(--selected-bg)' : 'var(--surface-3)',
}}
>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove() }}
style={{ position: 'absolute', top: 6, right: 6, zIndex: 10, color: 'var(--text-dim)' }}
className="w-6 h-6 rounded-md hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)] flex items-center justify-center text-base leading-none"
aria-label="삭제"
>
×
</button>
<div className="pt-3 px-3 pb-3 flex flex-col items-center text-center">
<div className="w-24 h-24 overflow-hidden flex items-center justify-center">
{char.character_image ? (
<img
src={char.character_image}
alt=""
className="w-full h-full object-contain scale-[3] origin-center pointer-events-none"
style={{ imageRendering: 'pixelated' }}
draggable={false}
/>
) : (
<span className="text-3xl" style={{ color: 'var(--text-dim)' }}>?</span>
)}
</div>
<div
className="mt-2 text-base font-semibold truncate w-full"
style={{ color: active ? 'var(--accent-bright)' : 'var(--text-emphasis)' }}
>
{char.character_name}
</div>
<div
className="text-xs tabular-nums mt-0.5 truncate w-full"
style={{ color: 'var(--text-dim)' }}
>
Lv.{char.character_level} · {char.job_name}
</div>
</div>
</div>
)
}
function SymbolCard({ symbol, equipped, charId }) {
const progress = useSymbolStore((s) => s.progress?.[charId]?.[symbol.id])
const updateSymbol = useSymbolStore((s) => s.updateSymbol)
const dailyDone = progress?.dailyDone ?? false
const weeklyCount = progress?.weeklyCount ?? 3
const daily = progress?.daily ?? symbol.daily_default
const extra = progress?.extra ?? 0
const patch = (p) => charId && updateSymbol(charId, symbol.id, p)
const level = progress?.level ?? 0
const growth = progress?.growth ?? 0
const requireGrowth = symbol.levels?.find((l) => l.level === level)?.required_count || 0
const isMax = equipped && level >= symbol.max_level
// : ( )
// :
// :
const { remainingSymbols, remainingMeso, arrearMeso } = useMemo(() => {
if (!equipped || !symbol.levels?.length) return { remainingSymbols: 0, remainingMeso: 0, arrearMeso: 0 }
let sym = 0, meso = 0, arr = 0
// :
let arrLv = level, arrG = growth
while (arrLv < symbol.max_level) {
const req = symbol.levels.find((l) => l.level === arrLv)?.required_count
const cost = symbol.levels.find((l) => l.level === arrLv)?.meso_cost
if (req == null || cost == null || arrG < req) break
arr += cost
arrG -= req
arrLv += 1
}
let g = growth
for (const l of symbol.levels) {
if (l.level < level) continue
sym += Math.max(l.required_count - g, 0)
g = Math.max(g - l.required_count, 0)
meso += l.meso_cost
}
return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr }
}, [equipped, level, growth, symbol.levels, symbol.max_level])
// ( )
const reachableLevel = useMemo(() => {
if (!equipped || isMax) return level
let lv = level
let g = growth
while (lv < symbol.max_level) {
const req = symbol.levels?.find((l) => l.level === lv)?.required_count
if (!req || g < req) break
g -= req
lv += 1
}
return lv
}, [equipped, isMax, level, growth, symbol.levels, symbol.max_level])
//
const effectivelyMax = equipped && !isMax && reachableLevel >= symbol.max_level
const interactable = equipped && !isMax && !effectivelyMax
// /
const { days: daysLeft, date: completeDate } = useMemo(() => {
if (!equipped || isMax) return { days: null, date: null }
return computeCompletion({
remainingSymbols,
daily,
weeklyPerWeek: (weeklyCount || 0) * (symbol.weekly_default || 0),
extra,
dailyDone,
})
}, [equipped, isMax, remainingSymbols, daily, weeklyCount, symbol.weekly_default, extra, dailyDone])
const inputClass = "w-full h-10 rounded-md border px-3 text-base text-right tabular-nums outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)] disabled:opacity-50"
return (
<div
className="rounded-2xl border p-5"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
opacity: equipped ? 1 : 0.6,
}}
>
<div className="flex items-center gap-3 mb-4">
<div
className="w-14 h-14 rounded-lg overflow-hidden shrink-0 flex items-center justify-center"
style={{ background: 'var(--surface-nested)' }}
>
{symbol.image_url && (
<img
src={symbol.image_url}
alt={symbol.region}
className={`w-12 h-12 object-contain ${!equipped ? 'grayscale opacity-50' : ''}`}
style={{ imageRendering: 'pixelated' }}
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-base font-semibold truncate">{symbol.region}</div>
<div
className="text-sm tabular-nums mt-0.5"
style={{ color: 'var(--text-muted)' }}
>
Lv.<span className="font-bold text-base" style={{ color: 'var(--accent-bright)' }}>{level}</span>
<span style={{ color: 'var(--text-dim)' }}> / {symbol.max_level}</span>
</div>
</div>
{equipped && !isMax && !effectivelyMax && (
<button
type="button"
onClick={() => patch({ dailyDone: !dailyDone })}
title="오늘 일퀘 완료 여부"
className="shrink-0 rounded-md h-8 px-3 text-xs font-semibold border disabled:opacity-40 disabled:cursor-not-allowed"
style={dailyDone ? {
background: 'var(--selected-bg)',
borderColor: 'var(--selected-border)',
color: 'var(--accent-bright)',
} : {
background: 'var(--danger-bg-hover)',
borderColor: 'var(--icon-danger-border)',
color: 'var(--danger-text)',
}}
>
{dailyDone ? '금일 일퀘 완료' : '금일 일퀘 미완료'}
</button>
)}
</div>
{/* 진행도 바 */}
<div className="mb-4">
<div className="flex justify-between text-sm tabular-nums mb-1.5">
{isMax ? (
<span style={{ color: 'var(--text-muted)' }}>
성장치 <span className="font-bold" style={{ color: 'var(--warning-text-bright)' }}>MAX</span>
</span>
) : effectivelyMax ? (
<Tooltip text={`Lv.${symbol.max_level}까지 상승 가능`}>
<span style={{ color: 'var(--text-muted)' }}>
성장치 {growth} <span className="font-bold" style={{ color: 'var(--warning-text-bright)' }}>(MAX)</span> / {requireGrowth}
</span>
</Tooltip>
) : reachableLevel > level ? (
<Tooltip text={`Lv.${reachableLevel}까지 상승 가능`}>
<span style={{ color: 'var(--text-muted)' }}>
성장치 {growth} / {requireGrowth}
</span>
</Tooltip>
) : (
<span style={{ color: 'var(--text-muted)' }}>
성장치 {growth} / {requireGrowth}
</span>
)}
{!isMax && !effectivelyMax && (
<span style={{ color: 'var(--text-muted)' }}>
{requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}%
</span>
)}
</div>
<div
className="h-2 rounded-full overflow-hidden"
style={{ background: 'var(--progress-track)' }}
>
<div
className="h-full transition-all"
style={{
width: isMax || effectivelyMax ? '100%' : `${Math.min((growth / requireGrowth) * 100, 100)}%`,
background: isMax || effectivelyMax ? 'var(--progress-amber)' : 'var(--progress-emerald)',
}}
/>
</div>
</div>
{/* 획득량 입력 */}
<div
className="grid gap-2 mb-4"
style={{ gridTemplateColumns: symbol.weekly_default > 0 ? '0.7fr 1.3fr 1fr' : '1fr 1fr' }}
>
<div className="space-y-1">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>일퀘 획득</label>
<input
type="text"
inputMode="numeric"
value={equipped ? String(daily) : '0'}
onChange={(e) => patch({ daily: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
disabled={!interactable}
className={inputClass}
style={{
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}}
/>
</div>
{symbol.weekly_default > 0 && (
<div className="space-y-1">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>주간퀘 획득</label>
<Select
value={weeklyCount}
onChange={(v) => patch({ weeklyCount: v })}
options={[0, 1, 2, 3].map((n) => ({
value: n,
label: `${n * symbol.weekly_default}`,
}))}
disabled={!interactable}
/>
</div>
)}
<div className="space-y-1">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>추가 심볼</label>
<input
type="text"
inputMode="numeric"
value={equipped ? String(extra) : '0'}
onChange={(e) => patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
disabled={!interactable}
className={inputClass}
style={{
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}}
/>
</div>
</div>
{/* 정보 */}
<div className="text-base">
{[
{ label: '남은 심볼', value: equipped && !isMax && !effectivelyMax ? `${remainingSymbols.toLocaleString()}` : '-', color: 'var(--text-emphasis)' },
{ label: '필요 메소', value: equipped && !isMax ? remainingMeso.toLocaleString() : '-', color: 'var(--warning-text-bright)', tooltip: equipped && !isMax ? formatMesoKorean(remainingMeso) : null },
{ label: '체납 메소', value: equipped && !isMax ? arrearMeso.toLocaleString() : '-', color: 'var(--danger-text)', tooltip: equipped && !isMax ? formatMesoKorean(arrearMeso) : null },
{ label: '남은 일수', value: equipped && !isMax && !effectivelyMax && daysLeft != null ? `${daysLeft.toLocaleString()}` : '-', color: 'var(--text-emphasis)' },
{ label: '예상 완료일', value: equipped && !isMax && !effectivelyMax && completeDate ? formatKoreanDate(completeDate) : '-', color: equipped && !isMax && !effectivelyMax && completeDate ? 'var(--accent-bright)' : 'var(--text-dim)', strong: true },
].map((row, i) => (
<div
key={row.label}
className="flex justify-between py-2 border-t first:border-t-0"
style={{ borderColor: 'var(--row-divider)' }}
>
<span style={{ color: 'var(--text-muted)' }}>{row.label}</span>
{row.tooltip ? (
<Tooltip text={row.tooltip}>
<span className={`tabular-nums ${row.strong ? 'font-semibold' : 'font-medium'}`} style={{ color: row.color }}>
{row.value}
</span>
</Tooltip>
) : (
<span className={`tabular-nums ${row.strong ? 'font-semibold' : 'font-medium'}`} style={{ color: row.color }}>
{row.value}
</span>
)}
</div>
))}
</div>
</div>
)
}
export default function Symbol() {
const { setFullscreen } = useLayout()
useEffect(() => {
setFullscreen(true)
return () => setFullscreen(false)
}, [setFullscreen])
// (DB )
const { data: allSymbols = [] } = useQuery({
queryKey: ['symbol', 'symbols'],
queryFn: () => api('/api/symbols').catch(() => []),
staleTime: 5 * 60 * 1000,
})
const tabs = useMemo(() => {
const groups = {}
for (const s of allSymbols) {
if (!groups[s.type]) groups[s.type] = s
}
return TYPE_ORDER
.filter((t) => groups[t])
.map((t) => ({ key: t, label: `${t} 심볼`, image_url: groups[t].image_url }))
}, [allSymbols])
const characters = useSymbolStore((s) => s.characters)
const selectedCharId = useSymbolStore((s) => s.selectedCharId)
const addCharacter = useSymbolStore((s) => s.addCharacter)
const removeCharacter = useSymbolStore((s) => s.removeCharacter)
const selectCharacter = useSymbolStore((s) => s.selectCharacter)
const syncCharacterSymbols = useSymbolStore((s) => s.syncCharacterSymbols)
const updateCharacter = useSymbolStore((s) => s.updateCharacter)
const storedTab = useSymbolStore((s) => s.selectedTabs?.[selectedCharId])
const setTabStore = useSymbolStore((s) => s.setTab)
const tab = storedTab || tabs[0]?.key || null
const setTab = (t) => { if (selectedCharId) setTabStore(selectedCharId, t) }
// ( )
const basicQueries = useQueries({
queries: characters.map((c) => ({
queryKey: ['character', 'basic', c.character_name],
queryFn: () => api(`/api/character/search?name=${encodeURIComponent(c.character_name)}`),
enabled: !!c.character_name,
refetchOnMount: 'always',
staleTime: 0,
retry: false,
})),
})
useEffect(() => {
characters.forEach((c, idx) => {
const d = basicQueries[idx]?.data
if (!d) return
if (d.character_image !== c.character_image || d.character_level !== c.character_level || d.job_name !== c.job_name) {
updateCharacter(c.id, {
character_image: d.character_image,
character_level: d.character_level,
job_name: d.job_name,
world_name: d.world_name,
})
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [basicQueries.map((q) => q.dataUpdatedAt).join(',')])
// fetch ( )
const symbolQueries = useQueries({
queries: characters.map((c) => ({
queryKey: ['character', 'symbols', c.id],
queryFn: () => api(`/api/character/symbols?ocid=${c.id}`),
enabled: !!c.id,
refetchOnMount: 'always',
staleTime: 0,
})),
})
// symbolQueries store
useEffect(() => {
if (!allSymbols.length || !characters.length) return
// (type, region) symbol id
const lookup = {}
for (const s of allSymbols) lookup[`${s.type}|${s.region}`] = s
characters.forEach((c, idx) => {
const q = symbolQueries[idx]
if (!q?.data?.symbols) return
const equippedMap = {}
for (const es of q.data.symbols) {
const match = lookup[`${es.type}|${es.region}`]
if (!match) continue
equippedMap[match.id] = {
level: es.level,
growth: es.growth_count,
require_growth: es.require_growth_count,
}
}
syncCharacterSymbols(c.id, equippedMap)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allSymbols, symbolQueries.map((q) => q.dataUpdatedAt).join(',')])
const [addName, setAddName] = useState('')
const [addError, setAddError] = useState('')
const symbols = allSymbols.filter((s) => s.type === tab)
const tabInfo = tabs.find((t) => t.key === tab)
const searchMutation = useMutation({
mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`),
onSuccess: (data) => {
if (characters.find((c) => c.character_name === data.character_name)) {
setAddError('이미 추가된 캐릭터입니다')
return
}
setAddError('')
setAddName('')
addCharacter(data)
},
onError: (err) => setAddError(err.message || '조회 실패'),
})
const handleSearch = (e) => {
e.preventDefault()
const n = addName.trim()
if (!n) return
setAddError('')
searchMutation.mutate(n)
}
const progress = useSymbolStore((s) => s.progress[selectedCharId])
const isEquipped = (symbolId) => !!progress?.[symbolId]?.equipped
// +
const { totalRequiredMeso, totalArrearMeso, overallDate } = useMemo(() => {
let req = 0, arr = 0, latest = null
for (const s of symbols) {
const p = progress?.[s.id]
if (!p?.equipped) continue
if (p.level >= s.max_level) continue
//
let lv = p.level, g = p.growth || 0
while (lv < s.max_level) {
const r = s.levels?.find((l) => l.level === lv)?.required_count
if (!r || g < r) break
g -= r; lv += 1
}
const effMax = lv >= s.max_level
// ( cascade)
let arrLv = p.level, arrG = p.growth || 0
while (arrLv < s.max_level) {
const lv = s.levels?.find((x) => x.level === arrLv)
if (!lv || arrG < lv.required_count) break
arr += lv.meso_cost
arrG -= lv.required_count
arrLv += 1
}
let remaining = 0
let gg = p.growth || 0
for (const l of s.levels || []) {
if (l.level < p.level) continue
remaining += Math.max(l.required_count - gg, 0)
gg = Math.max(gg - l.required_count, 0)
req += l.meso_cost
}
if (effMax) continue //
const { date } = computeCompletion({
remainingSymbols: remaining,
daily: p.daily ?? s.daily_default ?? 0,
weeklyPerWeek: (p.weeklyCount ?? 3) * (s.weekly_default || 0),
extra: p.extra || 0,
dailyDone: !!p.dailyDone,
})
if (date && (!latest || date > latest)) latest = date
}
return { totalRequiredMeso: req, totalArrearMeso: arr, overallDate: latest }
}, [symbols, progress])
return (
<div className="space-y-6 pb-10 max-w-5xl mx-auto">
{/* 캐릭터 조회 */}
<div
className="rounded-2xl border p-5 space-y-4"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: 'var(--input-icon)' }}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="8" cy="8" r="5" stroke="currentColor" strokeWidth="1.5" />
<path d="M12 12L16 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</span>
<input
type="text"
value={addName}
onChange={(e) => { setAddName(e.target.value); if (addError) setAddError('') }}
placeholder="캐릭터 닉네임으로 장착 심볼 불러오기"
className="w-full h-12 box-border rounded-lg border pl-10 pr-4 text-base outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
style={{
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}}
/>
</div>
<button
type="submit"
disabled={searchMutation.isPending}
className="shrink-0 rounded-lg disabled:opacity-50 px-6 h-12 text-base font-semibold hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
{searchMutation.isPending ? '...' : '조회'}
</button>
</form>
{addError && (
<p className="text-sm" style={{ color: 'var(--danger-text)' }}>{addError}</p>
)}
{/* 캐릭터 목록 */}
{characters.length > 0 && (
<div className="flex items-start gap-3 overflow-x-auto pt-1">
{characters.map((c) => (
<CharacterCard
key={c.id}
char={c}
active={c.id === selectedCharId}
onSelect={() => selectCharacter(c.id)}
onRemove={() => removeCharacter(c.id)}
/>
))}
</div>
)}
</div>
{/* 심볼 타입 탭 */}
<div className="flex gap-2">
{tabs.map((t) => {
const active = tab === t.key
return (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className="flex-1 flex items-center justify-center gap-2.5 rounded-2xl border px-4 py-3"
style={active ? {
background: 'var(--selected-bg)',
borderColor: 'var(--selected-border)',
color: 'var(--accent-bright)',
boxShadow: 'var(--btn-primary-shadow)',
} : {
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
color: 'var(--text-muted)',
}}
>
{t.image_url ? (
<img src={t.image_url} alt="" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
) : (
<div className="w-8 h-8 rounded" style={{ background: 'var(--surface-nested)' }} />
)}
<span className="text-base font-semibold">{t.label}</span>
</button>
)
})}
</div>
{/* 심볼 카드 그리드 */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{symbols.map((s) => (
<SymbolCard key={s.id} symbol={s} equipped={isEquipped(s.id)} charId={selectedCharId} />
))}
</div>
{/* 전체 요약 */}
<div
className="rounded-2xl border p-6 flex items-center justify-between gap-6 flex-wrap"
style={{
background: 'var(--selected-bg)',
borderColor: 'var(--selected-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div>
<div className="text-base" style={{ color: 'var(--text-muted)' }}>
{tabInfo?.label} 전체 만렙 완료 예상일
</div>
<div
className="text-3xl font-bold tabular-nums mt-1.5"
style={{ color: 'var(--accent-bright)' }}
>
{overallDate ? formatKoreanDate(overallDate) : '-'}
</div>
</div>
<div className="flex items-center">
<div className="text-right pr-10">
<div className="text-base" style={{ color: 'var(--text-muted)' }}>누적 체납 메소</div>
<Tooltip text={formatMesoKorean(totalArrearMeso)}>
<div
className="text-2xl font-bold tabular-nums mt-1 inline-block"
style={{ color: 'var(--danger-text)' }}
>
{totalArrearMeso.toLocaleString()}
</div>
</Tooltip>
</div>
<div className="w-px h-12" style={{ background: 'var(--panel-border)' }} />
<div className="text-right pl-10">
<div className="text-base" style={{ color: 'var(--text-muted)' }}>남은 필요 메소</div>
<Tooltip text={formatMesoKorean(totalRequiredMeso)}>
<div
className="text-2xl font-bold tabular-nums mt-1 inline-block"
style={{ color: 'var(--warning-text-bright)' }}
>
{totalRequiredMeso.toLocaleString()}
</div>
</Tooltip>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,13 @@
import { Routes, Route } from 'react-router-dom'
import SymbolList from './admin/SymbolList'
import SymbolForm from './admin/SymbolForm'
export default function SymbolAdmin() {
return (
<Routes>
<Route index element={<SymbolList />} />
<Route path="symbols/new" element={<SymbolForm />} />
<Route path="symbols/:id" element={<SymbolForm />} />
</Routes>
)
}

View file

@ -0,0 +1,369 @@
import { useState, useRef, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../../api/client'
import Select from '../../../components/Select'
import ConfirmDialog from '../../../components/ConfirmDialog'
const TYPE_OPTIONS = [
{ value: '아케인', label: '아케인' },
{ value: '어센틱', label: '어센틱' },
{ value: '그랜드 어센틱', label: '그랜드 어센틱' },
]
const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition'
function formatMesoKorean(n) {
if (!n || n <= 0) return ''
const eok = Math.floor(n / 100_000_000)
const man = Math.floor((n % 100_000_000) / 10_000)
const parts = []
if (eok) parts.push(`${eok}`)
if (man) parts.push(`${man.toLocaleString()}`)
if (!parts.length) return `${n.toLocaleString()}`
return parts.join(' ')
}
function MesoInput({ value, onChange, ...rest }) {
const display = value === '' || value == null ? '' : Number(String(value).replace(/[^\d]/g, '')).toLocaleString()
const korean = formatMesoKorean(Number(String(value).replace(/[^\d]/g, '')) || 0)
return (
<div>
<input
type="text"
inputMode="numeric"
value={display}
onChange={(e) => {
const digits = e.target.value.replace(/[^\d]/g, '')
onChange(digits)
}}
className={`${inputCls} tabular-nums text-right`}
{...rest}
/>
<div className="text-sm text-amber-300 mt-1 text-right tabular-nums min-h-[18px]">{korean || '\u00A0'}</div>
</div>
)
}
function Field({ label, hint, error, required, children }) {
return (
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<label className="text-sm font-medium text-gray-300">
{label} {required && <span className="text-red-400">*</span>}
</label>
{hint && <span className="text-xs text-gray-500">{hint}</span>}
</div>
{children}
{error && <div className="text-[11px] text-red-400">{error}</div>}
</div>
)
}
export default function SymbolForm() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { id } = useParams()
const isEdit = !!id
const fileInputRef = useRef(null)
const [type, setType] = useState('아케인')
const [region, setRegion] = useState('')
const [maxLevel, setMaxLevel] = useState('')
const [dailyDefault, setDailyDefault] = useState('')
const [weeklyDefault, setWeeklyDefault] = useState('')
const [imageFile, setImageFile] = useState(null)
const [imagePreview, setImagePreview] = useState(null)
const [existingImageUrl, setExistingImageUrl] = useState(null)
const [levels, setLevels] = useState([])
const [confirmDelete, setConfirmDelete] = useState(false)
const [error, setError] = useState('')
//
const { data: symbolData } = useQuery({
queryKey: ['admin', 'symbol', 'symbols', id],
queryFn: () => api(`/api/admin/symbol/symbols/${id}`),
enabled: isEdit,
})
useEffect(() => {
if (!symbolData) return
setType(symbolData.type)
setRegion(symbolData.region)
setMaxLevel(String(symbolData.max_level))
setDailyDefault(String(symbolData.daily_default ?? ''))
setWeeklyDefault(String(symbolData.weekly_default ?? ''))
setExistingImageUrl(symbolData.image_url)
const rows = Array.from({ length: symbolData.max_level - 1 }, (_, i) => {
const level = i + 1
const existing = symbolData.levels.find((l) => l.level === level)
return {
level,
required_count: existing?.required_count ?? '',
meso_cost: existing?.meso_cost ?? '',
}
})
setLevels(rows)
}, [symbolData])
const handleFile = (e) => {
const file = e.target.files?.[0]
if (!file) return
setImageFile(file)
setImagePreview(URL.createObjectURL(file))
}
const updateLevel = (idx, field, val) => {
setLevels((prev) => prev.map((l, i) => (i === idx ? { ...l, [field]: val } : l)))
}
const adjustLevelRows = (newMax) => {
const n = Number(newMax)
if (!n || n < 2) return
setLevels((prev) => {
const rows = Array.from({ length: n - 1 }, (_, i) => {
const level = i + 1
return prev.find((l) => l.level === level) || { level, required_count: '', meso_cost: '' }
})
return rows
})
}
const saveMutation = useMutation({
mutationFn: async () => {
const formData = new FormData()
formData.append('type', type)
formData.append('region', region.trim())
formData.append('max_level', String(maxLevel))
formData.append('daily_default', String(Number(dailyDefault) || 0))
formData.append('weekly_default', String(Number(weeklyDefault) || 0))
formData.append('levels', JSON.stringify(
levels
.filter((l) => l.required_count !== '' || l.meso_cost !== '')
.map((l) => ({
level: l.level,
required_count: Number(l.required_count) || 0,
meso_cost: Number(l.meso_cost) || 0,
}))
))
if (imageFile) formData.append('image', imageFile)
const adminKey = localStorage.getItem('maple-admin-key')
const url = isEdit ? `/api/admin/symbol/symbols/${id}` : '/api/admin/symbol/symbols'
const res = await fetch(url, {
method: isEdit ? 'PATCH' : 'POST',
headers: { 'x-admin-key': adminKey },
body: formData,
})
const json = await res.json()
if (!res.ok) throw new Error(json.error || '저장 실패')
return json
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'symbol', 'symbols'] })
queryClient.invalidateQueries({ queryKey: ['symbol', 'symbols'] })
navigate('..')
},
onError: (err) => setError(err.message),
})
const deleteMutation = useMutation({
mutationFn: () => api(`/api/admin/symbol/symbols/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'symbol', 'symbols'] })
queryClient.invalidateQueries({ queryKey: ['symbol', 'symbols'] })
navigate('..')
},
onError: (err) => alert(err.message),
})
const handleSubmit = () => {
setError('')
if (!type) return setError('심볼 종류를 선택해주세요')
if (!region.trim()) return setError('지역 이름을 입력해주세요')
if (!maxLevel || Number(maxLevel) < 2) return setError('만렙을 입력해주세요')
if (!isEdit && !imageFile) return setError('심볼 이미지를 업로드해주세요')
saveMutation.mutate()
}
const displayImage = imagePreview || existingImageUrl
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h2 className="text-lg font-semibold">{isEdit ? '심볼 편집' : '심볼 추가'}</h2>
<p className="text-sm text-gray-500 mt-0.5">심볼 정보와 레벨별 필요 개수/메소를 입력합니다</p>
</div>
{/* 기본 정보 */}
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-6 space-y-5">
<div className="text-sm font-semibold text-emerald-300">기본 정보</div>
<Field label="심볼 이미지" required={!isEdit}>
<label className="flex items-center gap-4 rounded-xl border-2 border-dashed border-white/10 hover:border-emerald-500/40 hover:bg-emerald-500/5 bg-gray-950/50 p-4 transition cursor-pointer">
<div className="w-32 h-32 rounded-lg bg-gray-900 border border-white/5 flex items-center justify-center overflow-hidden shrink-0">
{displayImage ? (
<img src={displayImage} alt="" className="w-full h-full object-contain" style={{ imageRendering: 'pixelated' }} />
) : (
<span className="text-5xl text-gray-700">+</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-300">
{displayImage ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
</div>
<p className="text-xs text-gray-500 mt-1">PNG, JPG, GIF WebP로 자동 변환됩니다</p>
{imageFile && (
<div className="text-xs text-emerald-400 mt-2 truncate">📎 {imageFile.name}</div>
)}
</div>
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
</label>
</Field>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="심볼 종류" required>
<Select value={type} onChange={setType} options={TYPE_OPTIONS} />
</Field>
<Field label="지역 이름" required hint="예: 소멸의 여로">
<input
type="text"
value={region}
onChange={(e) => setRegion(e.target.value)}
className={inputCls}
placeholder="소멸의 여로"
/>
</Field>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Field label="만렙" required>
<input
type="number"
value={maxLevel}
onChange={(e) => { setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }}
className={inputCls}
min="2"
/>
</Field>
<Field label="기본 일퀘 획득량">
<input
type="number"
value={dailyDefault}
onChange={(e) => setDailyDefault(e.target.value)}
className={inputCls}
/>
</Field>
<Field label="기본 주간퀘 획득량">
<input
type="number"
value={weeklyDefault}
onChange={(e) => setWeeklyDefault(e.target.value)}
className={inputCls}
/>
</Field>
</div>
</div>
</div>
{/* 레벨별 설정 */}
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-6 space-y-4">
<div className="flex items-baseline justify-between">
<div className="text-sm font-semibold text-emerald-300">레벨별 필요 개수 · 메소</div>
<div className="text-xs text-gray-500">레벨 N N+1 업그레이드 기준 (만렙-1)</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase border-b border-white/5">
<th className="py-2 px-3 text-left font-medium w-20">레벨</th>
<th className="py-2 px-3 text-left font-medium">필요 심볼 </th>
<th className="py-2 px-3 text-left font-medium">메소</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{levels.map((l, idx) => (
<tr key={l.level}>
<td className="py-1.5 px-3 text-gray-400 tabular-nums">
Lv.<span className="text-gray-200 font-semibold">{l.level}</span>
<span className="text-gray-600 mx-1"></span>
{l.level + 1}
</td>
<td className="py-1.5 px-3">
<input
type="number"
value={l.required_count}
onChange={(e) => updateLevel(idx, 'required_count', e.target.value)}
className={`${inputCls} max-w-36`}
placeholder="0"
/>
</td>
<td className="py-1.5 px-3">
<div className="max-w-48">
<MesoInput
value={l.meso_cost}
onChange={(v) => updateLevel(idx, 'meso_cost', v)}
placeholder="0"
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 하단 버튼 */}
<div className="flex items-center justify-between gap-3">
<div>
{isEdit && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="rounded-lg border border-red-500/40 bg-red-500/10 hover:bg-red-500/20 text-red-300 px-4 py-2 text-sm font-medium transition"
>
삭제
</button>
)}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => navigate('..')}
className="rounded-lg border border-white/10 hover:bg-white/5 text-gray-300 px-4 py-2 text-sm transition"
>
취소
</button>
<button
type="button"
onClick={handleSubmit}
disabled={saveMutation.isPending}
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white px-5 py-2 text-sm font-semibold shadow-lg shadow-emerald-500/20 transition"
>
{saveMutation.isPending ? '저장 중...' : isEdit ? '저장' : '추가'}
</button>
</div>
</div>
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 text-red-300 text-sm px-4 py-2">
{error}
</div>
)}
<ConfirmDialog
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={() => { setConfirmDelete(false); deleteMutation.mutate() }}
title="심볼 삭제"
description={'이 심볼을 삭제하시겠습니까?\n레벨별 데이터도 함께 삭제됩니다.'}
confirmText="삭제"
destructive
/>
</div>
)
}

View file

@ -0,0 +1,180 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
DndContext, DragOverlay, closestCenter, PointerSensor, KeyboardSensor,
useSensor, useSensors,
} from '@dnd-kit/core'
import {
SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { api } from '../../../api/client'
const TYPE_COLOR = {
'아케인': { text: 'text-violet-300', bg: 'bg-violet-500/15', border: 'border-violet-500/30' },
'어센틱': { text: 'text-sky-300', bg: 'bg-sky-500/15', border: 'border-sky-500/30' },
'그랜드 어센틱': { text: 'text-amber-300', bg: 'bg-amber-500/15', border: 'border-amber-500/30' },
}
function SymbolCardContent({ symbol, dragging = false }) {
const color = TYPE_COLOR[symbol.type] || TYPE_COLOR['아케인']
return (
<div className={`flex items-stretch rounded-2xl border bg-gradient-to-br from-gray-900/80 to-gray-900/40 ${
dragging
? 'border-emerald-500/60 shadow-2xl shadow-emerald-500/30'
: 'border-white/5'
}`}>
<div className="flex items-center px-2 text-gray-700 cursor-grab active:cursor-grabbing">
<svg width="14" height="20" viewBox="0 0 14 20" fill="currentColor">
<circle cx="4" cy="4" r="1.5" /><circle cx="10" cy="4" r="1.5" />
<circle cx="4" cy="10" r="1.5" /><circle cx="10" cy="10" r="1.5" />
<circle cx="4" cy="16" r="1.5" /><circle cx="10" cy="16" r="1.5" />
</svg>
</div>
<div className="flex-1 min-w-0 flex items-start gap-3 p-4 pl-2">
<div className="shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden">
{symbol.image_url ? (
<img src={symbol.image_url} alt="" className="w-12 h-12 object-contain" style={{ imageRendering: 'pixelated' }} />
) : (
<span className="text-gray-700 text-2xl">?</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<h3 className="font-semibold truncate">{symbol.region}</h3>
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${color.text} ${color.bg} ${color.border}`}>
{symbol.type}
</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-gray-500 tabular-nums">
<span>만렙 {symbol.max_level}</span>
<span>일퀘 {symbol.daily_default}</span>
<span>주간퀘 {symbol.weekly_default}</span>
</div>
</div>
</div>
</div>
)
}
function SortableSymbolCard({ symbol }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging, setActivatorNodeRef } = useSortable({
id: symbol.id,
transition: { duration: 200, easing: 'cubic-bezier(0.25, 0.1, 0.25, 1)' },
})
const style = { transform: CSS.Transform.toString(transform), transition }
return (
<div ref={setNodeRef} style={style} className={`relative ${isDragging ? 'opacity-30' : ''}`}>
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="absolute left-0 top-0 bottom-0 w-8 z-10 cursor-grab active:cursor-grabbing rounded-l-2xl hover:bg-white/5 transition touch-none"
aria-label="순서 변경"
/>
<Link to={`symbols/${symbol.id}`} className="block group hover:[&_h3]:text-emerald-300 [&_h3]:transition">
<SymbolCardContent symbol={symbol} />
</Link>
</div>
)
}
export default function SymbolList() {
const queryClient = useQueryClient()
const { data: symbols = [], isLoading } = useQuery({
queryKey: ['admin', 'symbol', 'symbols'],
queryFn: () => api('/api/admin/symbol/symbols').catch(() => []),
})
const [items, setItems] = useState([])
const [activeId, setActiveId] = useState(null)
useEffect(() => { setItems(symbols) }, [symbols])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const reorderMutation = useMutation({
mutationFn: (ids) => api('/api/admin/symbol/symbols/reorder', {
method: 'POST',
body: { ids },
}),
onError: (err) => {
alert(err.message)
queryClient.invalidateQueries({ queryKey: ['admin', 'symbol', 'symbols'] })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['symbol', 'symbols'] })
},
})
const handleDragEnd = (event) => {
const { active, over } = event
setActiveId(null)
if (!over || active.id === over.id) return
const oldIdx = items.findIndex((s) => s.id === active.id)
const newIdx = items.findIndex((s) => s.id === over.id)
const next = arrayMove(items, oldIdx, newIdx)
setItems(next)
reorderMutation.mutate(next.map((s) => s.id))
}
const activeSymbol = items.find((s) => s.id === activeId)
return (
<div className="space-y-6">
<div className="flex items-end justify-between gap-4 flex-wrap">
<div>
<h2 className="text-lg font-semibold">심볼 관리</h2>
<p className="text-sm text-gray-500 mt-0.5">심볼 정보 레벨별 필요 개수/메소를 관리합니다</p>
</div>
<Link
to="symbols/new"
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 px-4 py-2 text-sm font-medium transition shadow-lg shadow-emerald-500/20"
>
<span className="text-base leading-none">+</span>
심볼 추가
</Link>
</div>
{isLoading ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 rounded-2xl bg-white/[0.02] animate-pulse" />
))}
</div>
) : items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
<div className="text-5xl mb-3 opacity-30">🔮</div>
<p className="text-gray-400 mb-4">등록된 심볼이 없습니다</p>
<Link to="symbols/new" className="text-sm text-emerald-400 hover:text-emerald-300 transition">
심볼 추가하기
</Link>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={(e) => setActiveId(e.active.id)}
onDragCancel={() => setActiveId(null)}
onDragEnd={handleDragEnd}
>
<SortableContext items={items.map((s) => s.id)} strategy={rectSortingStrategy}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((s) => (
<SortableSymbolCard key={s.id} symbol={s} />
))}
</div>
</SortableContext>
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.25, 0.1, 0.25, 1)' }}>
{activeSymbol ? <SymbolCardContent symbol={activeSymbol} dragging /> : null}
</DragOverlay>
</DndContext>
)}
</div>
)
}

View file

@ -0,0 +1,109 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* 심볼 계산기 상태
* characters: [{ id, character_name, character_level, job_name, character_image, ... }]
* selectedCharId: 현재 선택된 캐릭터 id (ocid)
* progress: {
* [charId]: {
* [symbolId]: {
* level: number,
* growth: number, // 현재 누적 성장치
* daily: number, // 일퀘 획득량 (기본값 수정 가능)
* weeklyCount: 1|2|3, // 주간퀘 횟수
* extra: number, // 추가 심볼
* dailyDone: boolean, // 금일 일퀘 완료 여부
* }
* }
* }
*/
export const useSymbolStore = create(persist(
(set, get) => ({
characters: [],
selectedCharId: null,
progress: {},
selectedTabs: {}, // { [charId]: '아케인' | '어센틱' | '그랜드 어센틱' }
setTab: (charId, tabKey) => set((s) => ({
selectedTabs: { ...s.selectedTabs, [charId]: tabKey },
})),
setCharacters: (next) => set((s) => ({
characters: typeof next === 'function' ? next(s.characters) : next,
})),
addCharacter: (char) => set((s) => {
if (s.characters.find((c) => c.character_name === char.character_name)) return s
const entry = { ...char, id: char.ocid }
return {
characters: [...s.characters, entry],
selectedCharId: entry.id,
}
}),
removeCharacter: (id) => set((s) => {
const nextProgress = { ...s.progress }
delete nextProgress[id]
return {
characters: s.characters.filter((c) => c.id !== id),
selectedCharId: s.selectedCharId === id ? null : s.selectedCharId,
progress: nextProgress,
}
}),
selectCharacter: (id) => set({ selectedCharId: id }),
updateCharacter: (id, patch) => set((s) => ({
characters: s.characters.map((c) => (c.id === id ? { ...c, ...patch } : c)),
})),
getSymbolState: (charId, symbolId) => get().progress?.[charId]?.[symbolId],
updateSymbol: (charId, symbolId, patch) => set((s) => {
const charProg = s.progress[charId] || {}
const symProg = charProg[symbolId] || {}
return {
progress: {
...s.progress,
[charId]: {
...charProg,
[symbolId]: { ...symProg, ...patch },
},
},
}
}),
resetCharacter: (charId) => set((s) => {
const next = { ...s.progress }
delete next[charId]
return { progress: next }
}),
/**
* API 응답을 store에 반영.
* equippedMap: { [symbolId]: { level, growth, require_growth } }
* - API에 있는 심볼: equipped=true, level/growth 갱신 (사용자 입력값인 daily/weeklyCount/extra/dailyDone은 유지)
* - API에 없는 심볼: equipped=false로 마킹
*/
syncCharacterSymbols: (charId, equippedMap) => set((s) => {
const charProg = { ...(s.progress[charId] || {}) }
// 기존 equipped를 false로 초기화
for (const k of Object.keys(charProg)) {
charProg[k] = { ...charProg[k], equipped: false }
}
// 새 장착 정보 병합
for (const [sid, info] of Object.entries(equippedMap)) {
charProg[sid] = {
...(charProg[sid] || {}),
equipped: true,
level: info.level,
growth: info.growth,
require_growth: info.require_growth,
}
}
return { progress: { ...s.progress, [charId]: charProg } }
}),
}),
{ name: 'maple-symbol' },
))

View file

@ -6,10 +6,299 @@
--font-maple: "Maplestory", "Noto Sans KR", sans-serif;
}
/* 테마 토큰 - dark (default) */
:root {
color-scheme: dark;
--bg-from: #030712;
--bg-via: #030712;
--bg-to: #0f172a;
--scrollbar-thumb: rgba(255, 255, 255, 0.12);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.22);
--scrollbar-track: transparent;
--header-bg: rgba(3, 7, 18, 0.8);
--header-border: rgba(255, 255, 255, 0.05);
--text-strong: #ffffff;
--text-emphasis: #e5e7eb;
--text-muted: #9ca3af;
--text-dim: #6b7280;
--text-slash: rgba(255, 255, 255, 0.2);
--toggle-bg: rgba(17, 24, 39, 0.6);
--toggle-border: rgba(255, 255, 255, 0.1);
--toggle-thumb-from: #e5e7eb;
--toggle-thumb-to: #9ca3af;
--toggle-thumb-icon: #0f172a;
--card-bg-from: rgba(17, 24, 39, 0.8);
--card-bg-to: rgba(17, 24, 39, 0.4);
--card-border: rgba(255, 255, 255, 0.05);
--card-shadow: 0 4px 14px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--icon-box-from: #1f2937;
--icon-box-to: #111827;
--icon-box-border: rgba(255, 255, 255, 0.05);
--divider-line: rgba(255, 255, 255, 0.1);
--skeleton-bg: rgba(255, 255, 255, 0.02);
--empty-bg: rgba(17, 24, 39, 0.4);
--empty-border: rgba(255, 255, 255, 0.05);
--accent: #10b981;
--accent-hover-text: #6ee7b7;
--accent-glow: rgba(16, 185, 129, 0.1);
--panel-bg: rgba(17, 24, 39, 0.5);
--panel-border: rgba(255, 255, 255, 0.05);
--panel-shadow: 0 4px 14px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--row-divider: rgba(255, 255, 255, 0.04);
--row-hover-bg: rgba(255, 255, 255, 0.03);
--btn-bg: rgba(17, 24, 39, 0.6);
--btn-bg-hover: #1f2937;
--btn-border: rgba(255, 255, 255, 0.1);
--btn-border-hover: rgba(255, 255, 255, 0.2);
--dot-inactive: #4b5563;
--dot-inactive-hover: #6b7280;
--thumb-bg: #030712;
--thumb-placeholder: #374151;
--badge-emerald-bg: #059669;
--badge-amber-bg: #d97706;
--badge-gray-bg: #374151;
--badge-text: #ffffff;
--maintenance-text: #fbbf24;
--dashed-border: rgba(255, 255, 255, 0.1);
--surface-2: rgba(2, 6, 23, 0.6);
--surface-3: rgba(17, 24, 39, 0.4);
--surface-nested: #0f172a;
--input-bg: #020617;
--input-border: rgba(255, 255, 255, 0.1);
--input-border-hover: rgba(255, 255, 255, 0.2);
--input-border-focus: rgba(16, 185, 129, 0.5);
--input-placeholder: #6b7280;
--input-icon: #6b7280;
--selected-bg: rgba(16, 185, 129, 0.08);
--selected-border: rgba(16, 185, 129, 0.4);
--option-selected-bg: rgba(16, 185, 129, 0.12);
--option-selected-text: #6ee7b7;
--btn-primary-bg: #059669;
--btn-primary-bg-hover: #10b981;
--btn-primary-text: #ffffff;
--btn-primary-shadow: 0 4px 14px rgba(16, 185, 129, 0.2);
--danger-text: #f87171;
--danger-text-strong: #dc2626;
--danger-bg-hover: rgba(239, 68, 68, 0.1);
--warning-text: #fbbf24;
--warning-text-bright: #fcd34d;
--warning-text-dim: rgba(252, 211, 77, 0.4);
--progress-track: #0f172a;
--progress-emerald: #10b981;
--progress-amber: #f59e0b;
--accent-bright: #6ee7b7;
--accent-muted: rgba(16, 185, 129, 0.1);
--accent-text-on-emerald: #ecfdf5;
--tooltip-bg: #111827;
--tooltip-border: rgba(255, 255, 255, 0.1);
--tooltip-text: #e5e7eb;
--popup-bg: #111827;
--popup-border: rgba(255, 255, 255, 0.1);
--popup-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
--disabled-opacity: 0.3;
--inactive-filter: brightness(0.4);
--dialog-bg-from: #111827;
--dialog-bg-to: #030712;
--dialog-border: rgba(255, 255, 255, 0.1);
--dialog-backdrop: rgba(0, 0, 0, 0.7);
--icon-danger-bg: rgba(239, 68, 68, 0.1);
--icon-danger-border: rgba(239, 68, 68, 0.3);
--icon-info-bg: rgba(16, 185, 129, 0.1);
--icon-info-border: rgba(16, 185, 129, 0.3);
--ring-danger: rgba(239, 68, 68, 0.2);
--ring-info: rgba(16, 185, 129, 0.2);
--btn-danger-bg: #dc2626;
--btn-danger-bg-hover: #ef4444;
--btn-danger-shadow: 0 4px 14px rgba(239, 68, 68, 0.2);
--liberation-primary: #a78bfa;
--liberation-primary-bar: rgba(167, 139, 250, 0.5);
--liberation-secondary: #fda4af;
--liberation-secondary-bar: rgba(253, 164, 175, 0.5);
}
/* 테마 토큰 - light */
[data-theme="light"] {
color-scheme: light;
--bg-from: #ffffff;
--bg-via: #ffffff;
--bg-to: #ffffff;
--scrollbar-thumb: #a0a0a0;
--scrollbar-thumb-hover: #707070;
--scrollbar-track: transparent;
--header-bg: rgba(255, 255, 255, 0.8);
--header-border: rgba(0, 0, 0, 0.08);
--text-strong: #0f172a;
--text-emphasis: #1f2937;
--text-muted: #475569;
--text-dim: #64748b;
--text-slash: rgba(0, 0, 0, 0.2);
--toggle-bg: rgba(241, 245, 249, 0.9);
--toggle-border: rgba(0, 0, 0, 0.08);
--toggle-thumb-from: #fde68a;
--toggle-thumb-to: #f59e0b;
--toggle-thumb-icon: #78350f;
--card-bg-from: #ffffff;
--card-bg-to: #ffffff;
--card-border: rgba(0, 0, 0, 0.06);
--card-shadow: 0 2px 8px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04);
--icon-box-from: #f3f4f6;
--icon-box-to: #e5e7eb;
--icon-box-border: rgba(0, 0, 0, 0.06);
--divider-line: rgba(0, 0, 0, 0.1);
--skeleton-bg: rgba(0, 0, 0, 0.05);
--empty-bg: rgba(249, 250, 251, 0.9);
--empty-border: rgba(0, 0, 0, 0.06);
--accent: #059669;
--accent-hover-text: #047857;
--accent-glow: rgba(16, 185, 129, 0.12);
--panel-bg: #ffffff;
--panel-border: rgba(0, 0, 0, 0.06);
--panel-shadow: 0 2px 8px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04);
--row-divider: rgba(0, 0, 0, 0.06);
--row-hover-bg: rgba(0, 0, 0, 0.03);
--btn-bg: #ffffff;
--btn-bg-hover: #f3f4f6;
--btn-border: rgba(0, 0, 0, 0.08);
--btn-border-hover: rgba(0, 0, 0, 0.15);
--dot-inactive: #cbd5e1;
--dot-inactive-hover: #94a3b8;
--thumb-bg: #f3f4f6;
--thumb-placeholder: #cbd5e1;
--badge-emerald-bg: #059669;
--badge-amber-bg: #d97706;
--badge-gray-bg: #475569;
--badge-text: #ffffff;
--maintenance-text: #b45309;
--dashed-border: rgba(0, 0, 0, 0.12);
--surface-2: #f8fafc;
--surface-3: #f9fafb;
--surface-nested: #f3f4f6;
--input-bg: #ffffff;
--input-border: rgba(0, 0, 0, 0.12);
--input-border-hover: rgba(0, 0, 0, 0.22);
--input-border-focus: rgba(5, 150, 105, 0.55);
--input-placeholder: #9ca3af;
--input-icon: #94a3b8;
--selected-bg: rgba(16, 185, 129, 0.08);
--selected-border: rgba(5, 150, 105, 0.5);
--option-selected-bg: rgba(16, 185, 129, 0.12);
--option-selected-text: #047857;
--btn-primary-bg: #059669;
--btn-primary-bg-hover: #047857;
--btn-primary-text: #ffffff;
--btn-primary-shadow: 0 4px 14px rgba(16, 185, 129, 0.25);
--danger-text: #dc2626;
--danger-text-strong: #b91c1c;
--danger-bg-hover: rgba(220, 38, 38, 0.08);
--warning-text: #c2410c;
--warning-text-bright: #ea580c;
--warning-text-dim: rgba(234, 88, 12, 0.4);
--progress-track: #e5e7eb;
--progress-emerald: #10b981;
--progress-amber: #f59e0b;
--accent-bright: #047857;
--accent-muted: rgba(16, 185, 129, 0.1);
--accent-text-on-emerald: #ecfdf5;
--tooltip-bg: #111827;
--tooltip-border: rgba(255, 255, 255, 0.08);
--tooltip-text: #f3f4f6;
--popup-bg: #ffffff;
--popup-border: rgba(0, 0, 0, 0.1);
--popup-shadow: 0 10px 30px rgba(15, 23, 42, 0.15);
--disabled-opacity: 0.5;
--inactive-filter: opacity(0.25);
--dialog-bg-from: #ffffff;
--dialog-bg-to: #ffffff;
--dialog-border: rgba(0, 0, 0, 0.1);
--dialog-backdrop: rgba(15, 23, 42, 0.45);
--icon-danger-bg: rgba(220, 38, 38, 0.08);
--icon-danger-border: rgba(220, 38, 38, 0.3);
--icon-info-bg: rgba(16, 185, 129, 0.08);
--icon-info-border: rgba(5, 150, 105, 0.4);
--ring-danger: rgba(220, 38, 38, 0.15);
--ring-info: rgba(16, 185, 129, 0.18);
--btn-danger-bg: #dc2626;
--btn-danger-bg-hover: #b91c1c;
--btn-danger-shadow: 0 4px 14px rgba(220, 38, 38, 0.25);
--liberation-primary: #7c3aed;
--liberation-primary-bar: rgba(124, 58, 237, 0.5);
--liberation-secondary: #e11d48;
--liberation-secondary-bar: rgba(225, 29, 72, 0.5);
}
html, body, #root {
min-height: 100%;
background: linear-gradient(to bottom right, #030712, #030712, #0f172a);
background-color: var(--bg-from);
background-image: linear-gradient(to bottom right, var(--bg-from), var(--bg-via), var(--bg-to));
background-attachment: fixed;
transition:
background-color 500ms cubic-bezier(0.4, 0, 0.2, 1),
background-image 500ms cubic-bezier(0.4, 0, 0.2, 1);
}
html {
overscroll-behavior-y: contain;
}
/* OverlayScrollbars body 오버레이 테마 */
@ -22,6 +311,13 @@ html, body, #root {
--os-padding-axis: 2px;
}
/* 라이트 테마에서는 어두운 handle */
[data-theme="light"] .os-theme-maple.os-theme-dark {
--os-handle-bg: #a0a0a0;
--os-handle-bg-hover: #707070;
--os-handle-bg-active: #505050;
}
html {
font-family: "Maplestory", "Noto Sans KR", system-ui, sans-serif;
}
@ -59,26 +355,27 @@ input[type="number"] {
}
/* 내부 스크롤 영역만 얇은 커스텀 스크롤바 (메인 페이지 스크롤은 기본) */
*:not(html):not(body) {
/* 커스텀 스크롤바 (테마별) */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
*:not(html):not(body)::-webkit-scrollbar {
width: 8px;
height: 8px;
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*:not(html):not(body)::-webkit-scrollbar-track {
background: transparent;
*::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
*:not(html):not(body)::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 4px;
*::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 5px;
transition: background 0.2s;
}
*:not(html):not(body)::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.22);
*::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
*:not(html):not(body)::-webkit-scrollbar-corner {
*::-webkit-scrollbar-corner {
background: transparent;
}

View file

@ -10,15 +10,23 @@ 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" />
<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
className="h-px flex-1"
style={{ backgroundImage: 'linear-gradient(to right, transparent, var(--divider-line), transparent)' }}
/>
<span
className="text-xs uppercase tracking-widest"
style={{ color: 'var(--text-dim)' }}
>
Utilities
</span>
<div
className="h-px flex-1"
style={{ backgroundImage: 'linear-gradient(to right, transparent, var(--divider-line), transparent)' }}
/>
</div>
{/* 메뉴 그리드 */}
@ -26,13 +34,25 @@ export default function Home() {
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-32 rounded-2xl bg-white/[0.02] animate-pulse" />
<div
key={i}
className="h-32 rounded-2xl animate-pulse"
style={{ background: 'var(--skeleton-bg)' }}
/>
))}
</div>
) : menus.length === 0 ? (
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-16 text-center">
<div
className="rounded-2xl border p-16 text-center"
style={{ background: 'var(--empty-bg)', borderColor: 'var(--empty-border)' }}
>
<div className="text-5xl mb-4 opacity-50">🍁</div>
<p className="text-gray-400">아직 등록된 기능이 없습니다</p>
<p
className="transition-colors duration-500"
style={{ color: 'var(--text-muted)' }}
>
아직 등록된 기능이 없습니다
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@ -40,16 +60,27 @@ export default function Home() {
<Link
key={menu.id}
to={menu.url}
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-6 hover:border-emerald-500/30 transition-all duration-300"
className="relative rounded-2xl border p-6 transition-transform duration-300 hover:scale-[1.02] border-[var(--card-border)]"
style={{
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',
boxShadow: 'var(--card-shadow)',
}}
>
<div className="absolute -top-16 -right-16 w-40 h-40 rounded-full bg-emerald-500/0 group-hover:bg-emerald-500/10 blur-3xl transition-all duration-500" />
<div className="relative space-y-3">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden group-hover:scale-110 group-hover:border-emerald-500/30 transition-all duration-300">
<div className="space-y-3">
<div
className="w-12 h-12 rounded-xl border flex items-center justify-center overflow-hidden border-[var(--icon-box-border)]"
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
>
<img src={menu.image?.url || '/default.png'} alt={menu.title} className="w-9 h-9 object-contain" />
</div>
<div>
<h2 className="font-semibold group-hover:text-emerald-300 transition">{menu.title}</h2>
<p className="text-sm text-gray-400 mt-1 leading-relaxed">{menu.description}</p>
<h2 className="font-medium">{menu.title}</h2>
<p
className="text-sm mt-1 leading-relaxed"
style={{ color: 'var(--text-muted)' }}
>
{menu.description}
</p>
</div>
</div>
</Link>
@ -57,6 +88,27 @@ export default function Home() {
</div>
)}
</section>
{/* 구분선 */}
<div className="flex items-center gap-4">
<div
className="h-px flex-1"
style={{ backgroundImage: 'linear-gradient(to right, transparent, var(--divider-line), transparent)' }}
/>
<span
className="text-xs uppercase tracking-widest"
style={{ color: 'var(--text-dim)' }}
>
Notices
</span>
<div
className="h-px flex-1"
style={{ backgroundImage: 'linear-gradient(to right, transparent, var(--divider-line), transparent)' }}
/>
</div>
{/* 메이플 공지 */}
<NoticeWidget />
</div>
)
}

View file

@ -0,0 +1,11 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useThemeStore = create(persist(
(set) => ({
theme: 'dark',
setTheme: (theme) => set({ theme }),
toggleTheme: () => set((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })),
}),
{ name: 'maple-theme' },
))