Compare commits

...

23 commits

Author SHA1 Message Date
dc48f57501 비활성 요소의 금지 커서 제거
index.css 의 전역 button:disabled { cursor: not-allowed } 규칙과 개별
컴포넌트의 disabled:cursor-not-allowed / cursor-not-allowed 클래스를 모두
제거해 비활성 상태에서 기본 화살표 커서를 유지한다. opacity 등 기존 시각
피드백은 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:59:19 +09:00
edbaaf09aa 심볼 계산기에 이벤트 스킬(보약) 일퀘 보너스 자동 반영
Nexon Open API의 character/skill(grade=0) 응답에서 '그란디스/아케인리버
일일퀘스트 완료 시 획득 심볼 N개 증가' 문구를 파싱해 심볼 타입별 보너스를
일퀘 획득량 기본값에 바로 합산한다.

skill_level 필드는 이벤트 스킬에 한해 실제 레벨이 아닌 1로 고정 반환되므로
심볼 증가 개수 → 레벨 역산 테이블로 실제 레벨을 복원한다. 입력창 hover 시
'기본 X + 보약 Y (메이플 스위츠 Lv.Z)' 툴팁으로 근거를 노출.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:03:29 +09:00
3a1d8a63ac 이미지 선택 다이얼로그의 카드 툴팁 제거
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:30:02 +09:00
5368764f85 전역 툴팁 매니저 도입 + 기존 Tooltip 간소화
- components/common/GlobalTooltip.jsx 신설
  * document 전역 이벤트 위임으로 [title] / [data-tooltip] 자동 감지
  * title 속성 일시 제거로 브라우저 기본 툴팁 억제, 포털 렌더
  * data-tooltip-placement / data-tooltip-delay 옵션 지원
  * scroll/resize/Escape/mousedown 시 자동 숨김
- Tooltip.jsx는 자식을 <span title=... data-...> 로 감싸는 단순 래퍼로 변경
- App.jsx 루트에 GlobalTooltip 마운트
- 이제 전 코드베이스의 title="..." 가 모두 커스텀 툴팁으로 렌더됨

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:23:32 +09:00
7fc04cf371 보스 목록 스크롤을 OverlayScrollbars로 교체 + 좌우 여백
- features/boss-crystal/pc/user/BossSelector.jsx:
  * 목록 스크롤을 기본 overflow-y-auto → OverlayScrollbarsComponent
  * 메인 바디와 동일한 os-theme-maple os-theme-dark 테마
  * 헤더와 목록 좌우에 8px씩 추가 여백 (스크롤바와 내용 간격)
  * 헤더 행과 목록 row의 컬럼 정렬이 어긋나지 않도록 동기

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:11:00 +09:00
be548879dc 이미지 관리 다이얼로그 UX 정리
- Modal 공용 컴포넌트에 열기/닫기 애니메이션 추가, 뒷배경 클릭 닫기 제거
- ImagePicker에 동일한 애니메이션 + 뒷배경 클릭 차단 적용
- ImagePicker 이미지 크기를 관리 페이지와 동일하게 (p-4 + w-full h-full object-contain)
- ImagePicker 하단 빈 pagination 영역이 차지하던 여백 제거 (조건부 렌더)
- 그리드 높이를 632px로 고정 + OverlayScrollbars (os-theme-maple) 스크롤
- overscroll-behavior: contain 으로 뒷 페이지 스크롤 전파 방지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:03:10 +09:00
4720e33f26 썬데이 메이플 다이얼로그 UX 개선
- backdrop 폭/높이 100vw/100dvh 명시 → 뷰포트 하단까지 블러 적용
- 배경 스크롤 잠금 + OverlayScrollbars overscroll-behavior:contain
  → 다이얼로그 스크롤이 뒷 페이지로 전파되지 않음
- 다이얼로그 닫힘 exit 애니메이션 정상 동작 (AnimatePresence를 부모로 이동)
- '공식 공지 보기' 하단 링크 제거 → 우상단 외부 링크 아이콘 버튼 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:46:44 +09:00
18cc1855ac 썬데이 메이플 홈 배너 + 이미지 다이얼로그
- components/pc/SundayMapleBanner.jsx: 아이콘 + 라벨 버튼, 클릭 시 이미지 다이얼로그
- variant에 따라 '썬데이 메이플' / '스페셜 썬데이 메이플' 이미지 아이콘 사용
  (관리자 이미지 관리에서 업로드한 것)
- Home.jsx 상단에 배치 (금~일 available일 때만 렌더)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:31:16 +09:00
a94137bd4d 썬데이 메이플 자동 수집 백엔드
- sequelize 모델: sunday_maple (week_start/variant/image_url 등)
- services/sundayMaple.js: 이벤트 API 조회 + HTML 스크래핑 + rustfs 업로드 + DB 저장 공용 함수
- services/sundayMapleCron.js: 금요일 09:00 KST에 10초 간격 폴링 (최대 5분)
- routes/sunday-maple.js: GET /api/sunday-maple/current
  * 금/토/일만 available
  * DB 없으면 lazy fetch 시도 (cron miss 대비)
- 제목 파싱으로 normal/special variant 판별
- rustfs 경로: maplestory/sunday/{week_start}.png

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:30:30 +09:00
bc0c2b22f0 태블릿 전용 라우트 / 폴더 추가
- routes/tablet.jsx: 태블릿 placeholder
- App.jsx: isMobileOnly / isTablet / (그 외=PC) 3단 분기
- components/tablet/, pages/tablet/: 향후 태블릿 전용 컴포넌트·페이지 자리
- features/registry.js: tablet device 지원 (getTabletComponent 추가)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:07:31 +09:00
57715726b8 react-device-detect 도입 (PC / 모바일 라우트 분기)
- App.jsx: BrowserView / MobileView 로 분기 렌더
- 모바일 접속 시 routes/mobile.jsx (현재 '준비 중' placeholder) 렌더
- 구조 개편 후 실제 device 감지 활성화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:59:36 +09:00
0dd81b56e5 유틸리티 함수 단위 테스트 추가 (vitest)
- package.json: vitest 추가 + test/test:watch 스크립트
- utils/__tests__/formatting.test.js (7 tests)
- features/symbol/__tests__/utils.test.js (8 tests)
- features/liberation/__tests__/utils.test.js (18 tests)
- features/boss-crystal/pc/admin/__tests__/constants.test.js (6 tests)

총 39개 테스트 통과 (716ms)
- formatMeso / formatMesoKorean 경계 조건
- computeCompletion (심볼 완료 시뮬레이션)
- bossEarn, calcWeekPoints, calcDoneEarn, calcMonthlyEarn
- getSchedulerWeekRange (1주차/2주차, 목요일 시작 등)
- computeCompletionDate (단순 계산/주차별 계산 모드)
- 보스 결정 난이도 정의 및 스타일 헬퍼

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:41:48 +09:00
1646617069 리팩토링 6단계: Liberation 비즈니스 로직 utils.js로 추출 (458 → 299)
- features/liberation/utils.js 신설
  * bossEarn, calcWeekPoints, calcDoneEarn, calcMonthlyEarn
  * getSchedulerWeekRange
  * computeCompletionDate (파라미터로 state 주입받는 순수 함수)
- Liberation.jsx는 상태/렌더링/뷰모델만 담당 (299줄)
- WeeklyScheduler.jsx의 중복 bossEarn/calcWeeklySum/getWeekRange/makeEmptyWeek
  모두 utils에서 import (alias 유지)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:21:32 +09:00
4be648c21c 리팩토링 5단계: NoticeWidget.jsx 폴더로 분리 (431 → 4개 파일)
- components/pc/NoticeWidget/
  - index.jsx (38): 루트 + useQueries
  - config.js (62): SECTIONS 정의 + 날짜/배지 헬퍼
  - TextListSection.jsx (140): memo
  - CarouselSection.jsx (165): CardItem + memo

Home.jsx의 import 'components/pc/NoticeWidget'는 자동으로 index.jsx로 resolve

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:47:40 +09:00
569def6794 리팩토링 4단계: AdminImages.jsx 분리 (652 → 298 줄)
- components/common/Modal.jsx: 관리자 모달 공용 래퍼
- features/admin/pc/components/UploadModal.jsx (179줄)
- features/admin/pc/components/ImageCard.jsx (memo)
- features/admin/pc/components/Pagination.jsx
- AdminImages.jsx는 상태/mutations/렌더링 오케스트레이션만 담당

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:46:02 +09:00
1fe3ba0d12 리팩토링 3단계: Symbol.jsx 분리 (717 → 346 줄)
- features/symbol/utils.js: formatKoreanDate, computeCompletion, TYPE_ORDER
- features/symbol/pc/user/CharacterCard.jsx: 캐릭터 카드 (memo)
- features/symbol/pc/user/SymbolCard.jsx: 심볼 카드 (memo, 계산 로직 포함)
- Symbol.jsx: 검색/탭/그리드/요약 렌더링만 담당
- basicQueries/symbolQueries 배열을 useMemo로 감쌈 (매 렌더 재생성 방지)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:43:52 +09:00
f6f1e79b82 리팩토링 2단계: 성능 최적화 (메모화)
- SymbolCard / CharacterCard를 React.memo로 감쌈
  (심볼 그리드에서 형제 카드 변경 시 불필요 리렌더 방지)
- Liberation의 computeCompletionDate() 호출을 useMemo로 감쌈
  (520회 루프가 매 렌더마다 돌던 것을 관련 state 변경 시만 실행)
- Symbol.jsx의 로컬 formatMesoKorean 중복 정의 제거 (utils import)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:41:17 +09:00
c6ac3366cc 리팩토링 1단계: 공용 utils/FormField 추출
- utils/formatting.js 신설 (formatMeso, formatMesoKorean 통합)
- components/common/FormField.jsx 신설 (label+hint+error 공용 래퍼
  + formInputClass/formInputStyle 상수)
- 중복 정의 제거:
  * BossForm, SymbolForm, AdminMenuForm의 Field 로컬 정의 삭제
  * boss-crystal constants.js의 formatMeso → utils re-export
  * SymbolForm의 formatMesoKorean 로컬 정의 삭제
  * 3개 폼의 inputCls/inputStyle 상수 삭제

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:39:23 +09:00
4da16abc10 import path 깊이 수정
구조 개편 후 depth가 한 단계 깊어진 components/common, components/pc,
features/admin/pc/components 내부 파일들의 상대 import 경로 업데이트

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:37:15 +09:00
1ad25630bf 구조 개편 4단계: routes/ 신설 + App.jsx 단순화
- routes/pc.jsx: 기존 App.jsx의 Route 정의를 추출
- routes/mobile.jsx: 모바일 placeholder (준비 중 안내)
- App.jsx는 디바이스 분기 자리만 남김 (현재 PCRoutes만 렌더)
- react-device-detect 도입 준비 완료

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:27:24 +09:00
444cf8cf85 구조 개편 3단계: features/admin/pc/ + pages/pc/Home.jsx
- features/admin/* → features/admin/pc/* (AdminLayout, AdminHome,
  AdminImages, AdminMenuForm, AdminBoss, AdminFeaturePage, components/)
- pages/Home.jsx → pages/pc/Home.jsx
- App.jsx import path 업데이트

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:26:52 +09:00
b423d0ac82 구조 개편 2단계: features 내부에 pc/ 폴더 생성 + 이동
- features/boss-crystal/pc/: BossCrystal, BossCrystalAdmin, admin/, user/
- features/symbol/pc/: Symbol, SymbolAdmin, admin/
- features/liberation/pc/: Liberation, components/
- store.js, data.js는 feature 루트에 유지 (device 공용)
- registry.js: import.meta.glob 패턴을 './*/\{pc,mobile\}/*.jsx' 로 변경
- getMobileComponent 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:26:12 +09:00
4789c56dfa 구조 개편 1단계: components/ 를 common/pc/mobile/로 분리
- components/common/: Select, Tooltip, ConfirmDialog, DatePicker,
  Checkbox, LoginDialog, CharacterSuggestDropdown (device 독립)
- components/pc/: Layout, Footer, NoticeWidget (PC 전용)
- components/mobile/: (placeholder)
- 모든 import path 업데이트

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:24:43 +09:00
80 changed files with 5372 additions and 2865 deletions

View file

@ -0,0 +1,21 @@
import { DataTypes } from 'sequelize';
import { sequelize } from '../lib/db.js';
export const SundayMaple = sequelize.define('SundayMaple', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
// 해당 주의 금요일 (KST, YYYY-MM-DD)
week_start: { type: DataTypes.DATEONLY, allowNull: false, unique: true },
variant: {
type: DataTypes.ENUM('normal', 'special'),
allowNull: false,
defaultValue: 'normal',
},
event_post_id: { type: DataTypes.STRING(50) },
event_post_url: { type: DataTypes.STRING(500) },
source_image_url: { type: DataTypes.STRING(500) },
image_url: { type: DataTypes.STRING(500), allowNull: false },
fetched_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
}, {
tableName: 'sunday_maple',
underscored: true,
});

View file

@ -1,5 +1,6 @@
import { Image } from './Image.js';
import { Menu } from './Menu.js';
import { SundayMaple } from './SundayMaple.js';
import { BossCrystalBoss } from './boss-crystal/Boss.js';
import { BossCrystalBossDifficulty } from './boss-crystal/BossDifficulty.js';
import { Symbol } from './symbol/Symbol.js';
@ -24,4 +25,4 @@ Symbol.hasMany(SymbolLevel, {
});
SymbolLevel.belongsTo(Symbol, { foreignKey: 'symbol_id', as: 'symbol' });
export { Image, Menu, BossCrystalBoss, BossCrystalBossDifficulty, Symbol, SymbolLevel };
export { Image, Menu, SundayMaple, BossCrystalBoss, BossCrystalBossDifficulty, Symbol, SymbolLevel };

View file

@ -11,10 +11,12 @@
"@aws-sdk/client-s3": "^3.800.0",
"axios": "^1.9.0",
"cors": "^2.8.5",
"dayjs": "^1.11.20",
"express": "^5.1.0",
"mariadb": "^3.4.0",
"multer": "^2.0.0",
"mysql2": "^3.14.1",
"node-cron": "^4.2.1",
"sequelize": "^6.37.5",
"sharp": "^0.34.1"
}
@ -2314,6 +2316,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/dayjs": {
"version": "1.11.20",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -3051,6 +3059,15 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View file

@ -8,14 +8,16 @@
"dev": "node --watch server.js"
},
"dependencies": {
"express": "^5.1.0",
"cors": "^2.8.5",
"sequelize": "^6.37.5",
"mysql2": "^3.14.1",
"mariadb": "^3.4.0",
"@aws-sdk/client-s3": "^3.800.0",
"axios": "^1.9.0",
"cors": "^2.8.5",
"dayjs": "^1.11.20",
"express": "^5.1.0",
"mariadb": "^3.4.0",
"multer": "^2.0.0",
"mysql2": "^3.14.1",
"node-cron": "^4.2.1",
"sequelize": "^6.37.5",
"sharp": "^0.34.1"
}
}

View file

@ -46,16 +46,53 @@ router.get('/search', async (req, res) => {
}
});
// OCID로 장착 심볼 조회
// 이벤트 스킬(보약) 효과에서 일퀘 심볼 보너스 파싱
// Nexon API의 skill_level 필드는 이벤트 스킬에 한해 실제 레벨이 아닌 1로 고정되므로
// skill_effect 문자열의 심볼 증가 개수를 이용해 실제 레벨을 역산한다.
// (이벤트마다 테이블이 달라지면 아래 맵을 갱신해야 함)
const ARCANE_SYMBOL_TO_LEVEL = { 2: 1, 4: 2, 8: 3, 12: 4, 16: 5, 20: 6 };
const AUTHENTIC_SYMBOL_TO_LEVEL = { 2: 1, 3: 2, 4: 3, 5: 4, 7: 5, 9: 6 };
function parseEventSkillBonus(skills) {
for (const s of skills || []) {
const eff = s.skill_effect || '';
const arcane = eff.match(/아케인리버\s*일일퀘스트[^\r\n]*?획득\s*심볼\s*(\d+)\s*개/);
const authentic = eff.match(/그란디스\s*일일퀘스트[^\r\n]*?획득\s*심볼\s*(\d+)\s*개/);
if (arcane || authentic) {
const arcaneDaily = arcane ? Number(arcane[1]) || 0 : 0;
const authenticDaily = authentic ? Number(authentic[1]) || 0 : 0;
const derivedLevel =
AUTHENTIC_SYMBOL_TO_LEVEL[authenticDaily] ||
ARCANE_SYMBOL_TO_LEVEL[arcaneDaily] ||
0;
return {
skill_name: s.skill_name,
skill_level: derivedLevel,
arcane_daily: arcaneDaily,
authentic_daily: authenticDaily,
};
}
}
return null;
}
// 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 [symbolRes, skillRes] = await Promise.all([
axios.get(`${NEXON_API_BASE}/maplestory/v1/character/symbol-equipment`, {
params: { ocid },
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
}),
axios.get(`${NEXON_API_BASE}/maplestory/v1/character/skill`, {
params: { ocid, character_skill_grade: '0' },
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
}).catch(() => ({ data: { character_skill: [] } })),
]);
const data = symbolRes.data;
const parsed = (data.symbol || []).map((s) => {
const [prefix, region] = (s.symbol_name || '').split(' : ').map((t) => t.trim());
@ -70,7 +107,9 @@ router.get('/symbols', async (req, res) => {
};
});
res.json({ ocid, character_class: data.character_class, symbols: parsed });
const event_skill = parseEventSkillBonus(skillRes.data?.character_skill);
res.json({ ocid, character_class: data.character_class, symbols: parsed, event_skill });
} catch (err) {
const code = err.response?.data?.error?.name;
if (['OPENAPI00001', 'OPENAPI00007', 'OPENAPI00010', 'OPENAPI00011'].includes(code)) {

View file

@ -0,0 +1,45 @@
import { Router } from 'express';
import { SundayMaple } from '../models/index.js';
import {
currentWeekFriday,
isInSundayWindow,
fetchAndSaveSundayMaple,
} from '../services/sundayMaple.js';
const router = Router();
// 이번 주 썬데이 메이플 조회 (금~일만 available)
router.get('/current', async (req, res) => {
try {
if (!isInSundayWindow()) {
return res.json({ available: false });
}
const weekStart = currentWeekFriday();
let row = await SundayMaple.findOne({ where: { week_start: weekStart } });
// DB에 없으면 lazy fetch 시도 (cron이 놓친 경우 대비)
if (!row) {
try {
row = await fetchAndSaveSundayMaple();
} catch (err) {
console.error('[sunday-maple] lazy fetch 실패:', err.message);
}
}
if (!row) return res.json({ available: false });
res.json({
available: true,
variant: row.variant,
week_start: row.week_start,
image_url: row.image_url,
event_post_url: row.event_post_url,
});
} catch (err) {
console.error('[sunday-maple/current] 오류:', err);
res.status(500).json({ error: '조회 실패' });
}
});
export default router;

View file

@ -7,8 +7,10 @@ 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 sundayMapleRoutes from './routes/sunday-maple.js';
import { sequelize } from './lib/db.js';
import './models/index.js';
import { scheduleSundayMapleCron } from './services/sundayMapleCron.js';
const app = express();
const PORT = process.env.PORT || 3000;
@ -27,6 +29,7 @@ app.use('/api/boss-crystal', bossCrystalRoutes);
app.use('/api/character', characterRoutes);
app.use('/api/images', imageRoutes);
app.use('/api/symbols', symbolRoutes);
app.use('/api/sunday-maple', sundayMapleRoutes);
app.use('/api/admin', adminRoutes);
app.get('/api/health', (_req, res) => {
@ -40,6 +43,8 @@ async function start() {
await sequelize.sync();
console.log('테이블 동기화 완료');
scheduleSundayMapleCron();
app.listen(PORT, () => {
console.log(`서버 시작: http://localhost:${PORT}`);
});

View file

@ -0,0 +1,132 @@
import axios from 'axios';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import timezone from 'dayjs/plugin/timezone.js';
import { SundayMaple } from '../models/index.js';
import { uploadObject, getPublicUrl } from '../lib/s3.js';
dayjs.extend(utc);
dayjs.extend(timezone);
const KST = 'Asia/Seoul';
const NEXON_API_BASE = 'https://open.api.nexon.com';
const RUSTFS_PREFIX = 'sunday';
/**
* 제목을 파싱하여 썬데이 메이플 변형을 반환
* @returns {'normal'|'special'|null}
*/
export function detectVariant(title) {
if (!title) return null;
const t = title.trim();
if (!t.includes('썬데이') || !t.includes('메이플')) return null;
return t.includes('스페셜') ? 'special' : 'normal';
}
/**
* 금요일 기준의 이번 주차 시작일 (YYYY-MM-DD, KST) 반환
* // 직전 금요일
* /// 이전 금요일
*/
export function currentWeekFriday(now = dayjs().tz(KST)) {
const dow = now.day(); // 0=일 ... 5=금 6=토
// 금요일 기준 diff: 금(5)이면 0, 토(6)이면 -1, 일(0)이면 -2, 월(1)이면 -3 ...
const diff = dow >= 5 ? dow - 5 : dow + 2;
return now.startOf('day').subtract(diff, 'day').format('YYYY-MM-DD');
}
/**
* ~일요일인지
*/
export function isInSundayWindow(now = dayjs().tz(KST)) {
const dow = now.day();
return dow === 5 || dow === 6 || dow === 0;
}
/**
* Nexon 이벤트 API에서 이번 썬데이 메이플 항목 찾기
*/
async function findSundayPost() {
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/notice-event`, {
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
});
const list = data.event_notice || [];
const weekStart = currentWeekFriday();
// 제목 매칭 + 이번 주 시작
return list.find((n) => {
if (detectVariant(n.title) === null) return null;
const start = n.date_event_start ? dayjs(n.date_event_start).tz(KST).format('YYYY-MM-DD') : null;
// date_event_start가 금요일 아닐 수 있음 → 시작일이 해당 주 금요일과 같은 주인지 체크
if (!start) return false;
const sameWeek = dayjs(start).tz(KST).isSame(dayjs(weekStart).tz(KST), 'week')
// dayjs isSame 'week' 는 일요일 기준이라 금/토 확인 필요 → 직접 비교
|| Math.abs(dayjs(start).diff(dayjs(weekStart), 'day')) <= 2;
return sameWeek;
}) || null;
}
/**
* 게시글 HTML에서 번째 컨텐츠 PNG URL 추출
*/
async function scrapeImageUrl(postUrl) {
const { data: html } = await axios.get(postUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' },
});
const re = /<img[^>]+src=["'](https:\/\/lwi\.nexon\.com\/maplestory\/\d{4}\/\d{4}_board\/[^"']+\.png)["']/i;
const m = html.match(re);
return m ? m[1] : null;
}
/**
* Nexon CDN에서 이미지 다운로드 rustfs 업로드 URL 반환
*/
async function downloadAndUpload(sourceUrl, weekStart) {
const { data: buffer } = await axios.get(sourceUrl, {
responseType: 'arraybuffer',
headers: { 'User-Agent': 'Mozilla/5.0' },
});
const key = `${RUSTFS_PREFIX}/${weekStart}.png`;
await uploadObject(key, Buffer.from(buffer), 'image/png');
return { key, url: getPublicUrl(key) };
}
/**
* 핵심: 이번 썬데이 메이플을 Nexon에서 찾아 rustfs에 업로드하고 DB 저장.
* 이미 DB에 있으면 그대로 반환 (noop).
*
* @returns SundayMaple | null (게시글 아직 없음)
*/
export async function fetchAndSaveSundayMaple() {
const weekStart = currentWeekFriday();
// 이미 저장되어 있으면 스킵
const existing = await SundayMaple.findOne({ where: { week_start: weekStart } });
if (existing) return existing;
const post = await findSundayPost();
if (!post) return null;
const variant = detectVariant(post.title);
const sourceUrl = await scrapeImageUrl(post.url);
if (!sourceUrl) {
console.warn('[sunday-maple] 이미지 URL 못찾음:', post.url);
return null;
}
const { url: uploadedUrl } = await downloadAndUpload(sourceUrl, weekStart);
// 업로드 완료 후 DB insert (동시성 대비 findOrCreate)
const [row] = await SundayMaple.findOrCreate({
where: { week_start: weekStart },
defaults: {
week_start: weekStart,
variant,
event_post_id: String(post.notice_id || ''),
event_post_url: post.url,
source_image_url: sourceUrl,
image_url: uploadedUrl,
},
});
console.log('[sunday-maple] 저장 완료:', weekStart, variant, uploadedUrl);
return row;
}

View file

@ -0,0 +1,35 @@
import cron from 'node-cron';
import { fetchAndSaveSundayMaple } from './sundayMaple.js';
const POLL_INTERVAL_MS = 10_000; // 10초 간격
const MAX_DURATION_MS = 5 * 60 * 1000; // 최대 5분
/**
* 금요일 9시부터 10 간격 폴링 찾으면 저장 종료, 5 타임아웃.
*/
async function runPolling() {
const started = Date.now();
console.log('[sunday-maple cron] 폴링 시작');
while (Date.now() - started < MAX_DURATION_MS) {
try {
const row = await fetchAndSaveSundayMaple();
if (row) {
console.log('[sunday-maple cron] 저장 완료 → 종료');
return;
}
} catch (err) {
console.warn('[sunday-maple cron] 폴링 중 오류:', err.message);
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
console.log('[sunday-maple cron] 5분 타임아웃 → 종료 (lazy fallback이 커버)');
}
/**
* 매주 금요일 09:00 KST 실행
*/
export function scheduleSundayMapleCron() {
cron.schedule('0 9 * * 5', runPolling, { timezone: 'Asia/Seoul' });
console.log('[sunday-maple cron] 매주 금요일 09:00 KST 스케줄 등록');
}

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,9 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -19,6 +21,7 @@
"overlayscrollbars": "^2.15.1",
"overlayscrollbars-react": "^0.5.6",
"react": "^19.2.4",
"react-device-detect": "^2.2.3",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0",
"zustand": "^5.0.12"
@ -34,6 +37,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"vite": "^8.0.4"
"vite": "^8.0.4",
"vitest": "^3.2.4"
}
}

View file

@ -1,31 +1,20 @@
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Home from './pages/Home'
import FeaturePage from './features/FeaturePage'
import AdminLayout from './features/admin/AdminLayout'
import AdminHome from './features/admin/AdminHome'
import AdminImages from './features/admin/AdminImages'
import AdminMenuForm from './features/admin/AdminMenuForm'
import AdminFeaturePage from './features/admin/AdminFeaturePage'
import { isMobileOnly, isTablet } from 'react-device-detect'
import PCRoutes from './routes/pc'
import MobileRoutes from './routes/mobile'
import TabletRoutes from './routes/tablet'
import GlobalTooltip from './components/common/GlobalTooltip'
function Routes() {
if (isMobileOnly) return <MobileRoutes />
if (isTablet) return <TabletRoutes />
return <PCRoutes />
}
export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<Home />} />
{/* 관리자 */}
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminHome />} />
<Route path="images" element={<AdminImages />} />
<Route path="menus/new" element={<AdminMenuForm />} />
<Route path="menus/:id" element={<AdminMenuForm />} />
<Route path=":slug/*" element={<AdminFeaturePage />} />
</Route>
{/* 동적 기능 페이지 */}
<Route path="/:slug/*" element={<FeaturePage />} />
</Route>
</Routes>
<>
<Routes />
<GlobalTooltip />
</>
)
}

View file

@ -1,431 +0,0 @@
import { useState } from 'react'
import { useQueries } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { api } from '../api/client'
const SECTIONS = {
notice: { label: '메이플스토리 공지사항', dataKey: 'notice', pageSize: 5, kind: 'text' },
update: { label: '메이플스토리 업데이트', dataKey: 'update_notice', pageSize: 5, kind: 'text' },
event: {
label: '진행 중인 이벤트',
dataKey: 'event_notice',
pageSize: 3,
kind: 'card',
dateStartKey: 'date_event_start',
dateEndKey: 'date_event_end',
filterOngoing: true,
},
cashshop: {
label: '캐시샵 공지',
dataKey: 'cashshop_notice',
pageSize: 3,
kind: 'card',
dateStartKey: 'date_sale_start',
dateEndKey: 'date_sale_end',
filterOngoing: true,
},
}
function fmtMD(iso) {
if (!iso) return ''
const d = new Date(iso)
return `${d.getMonth() + 1}/${d.getDate()}`
}
function fmtYMD(iso) {
if (!iso) return ''
const d = new Date(iso)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function isRecent(iso, days = 3) {
if (!iso) return false
return (Date.now() - new Date(iso).getTime()) / 86400000 < days
}
function isOngoing(item, cfg) {
if (!cfg.filterOngoing) return true
const end = item[cfg.dateEndKey]
if (end) return new Date(end) > new Date()
if (item.ongoing_flag !== undefined) return item.ongoing_flag === 'true' || item.ongoing_flag === true
return false
}
function dayBadge(item, cfg) {
const now = Date.now()
const start = item[cfg.dateStartKey] ? new Date(item[cfg.dateStartKey]).getTime() : null
const end = item[cfg.dateEndKey] ? new Date(item[cfg.dateEndKey]).getTime() : null
if (start && start > now) {
const d = Math.ceil((start - now) / 86400000)
return { label: `시작 ${d}일 전`, tone: 'emerald' }
}
if (end) {
const d = Math.ceil((end - now) / 86400000)
if (d <= 0) return null
return { label: `종료 ${d}일 전`, tone: 'amber' }
}
if (item.ongoing_flag === 'true' || item.ongoing_flag === true) {
return { label: '상시판매', tone: 'gray' }
}
return null
}
/* ─── Text List Section ─────────────────────────────────────── */
function TextListSection({ cfg, items, isMaintenance, isLoading }) {
const [page, setPage] = useState(0)
const pages = Math.max(1, Math.ceil(items.length / cfg.pageSize))
const clamped = Math.min(page, pages - 1)
const slice = items.slice(clamped * cfg.pageSize, (clamped + 1) * cfg.pageSize)
return (
<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={item.url}
target="_blank"
rel="noopener noreferrer"
className="group relative block rounded-xl overflow-hidden border"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div
className="aspect-[2/1] overflow-hidden"
style={{ background: 'var(--thumb-bg)' }}
>
{item.thumbnail_url ? (
<img
src={item.thumbnail_url}
alt=""
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="text-xs tabular-nums"
style={{ color: 'var(--text-dim)' }}
>
{dateText}
</div>
</div>
</a>
)
}
function CarouselSection({ cfg, items, isMaintenance, isLoading }) {
const [page, setPage] = useState(0)
const pages = Math.max(1, Math.ceil(items.length / cfg.pageSize))
const clamped = Math.min(page, pages - 1)
const slice = items.slice(clamped * cfg.pageSize, (clamped + 1) * cfg.pageSize)
const navBtn = "w-7 h-7 rounded-md border flex items-center justify-center border-[var(--btn-border)] bg-[var(--btn-bg)] hover:bg-[var(--btn-bg-hover)] hover:border-[var(--btn-border-hover)] disabled:opacity-30 disabled:hover:bg-[var(--btn-bg)] disabled:hover:border-[var(--btn-border)]"
return (
<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
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={clamped === 0}
className={navBtn}
aria-label="이전"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7.5 3L4.5 6L7.5 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<span className="tabular-nums 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="relative overflow-x-clip pb-2">
{isLoading ? (
<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>
) : 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>
) : (
<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"
>
{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,113 +0,0 @@
import { useState, useRef, useEffect, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom'
/**
* 커스텀 툴팁
* <Tooltip text="설명">
* <button>...</button>
* </Tooltip>
*/
export default function Tooltip({ text, children, placement = 'top', delay = 200 }) {
const [open, setOpen] = useState(false)
const [coords, setCoords] = useState(null) // null ( )
const triggerRef = useRef(null)
const tooltipRef = useRef(null)
const timerRef = useRef(null)
const updatePosition = () => {
if (!triggerRef.current || !tooltipRef.current) return
const trigger = triggerRef.current.getBoundingClientRect()
const tooltip = tooltipRef.current.getBoundingClientRect()
const gap = 6
let top, left
switch (placement) {
case 'bottom':
top = trigger.bottom + gap
left = trigger.left + trigger.width / 2 - tooltip.width / 2
break
case 'left':
top = trigger.top + trigger.height / 2 - tooltip.height / 2
left = trigger.left - tooltip.width - gap
break
case 'right':
top = trigger.top + trigger.height / 2 - tooltip.height / 2
left = trigger.right + gap
break
case 'top':
default:
top = trigger.top - tooltip.height - gap
left = trigger.left + trigger.width / 2 - tooltip.width / 2
}
const padding = 4
left = Math.max(padding, Math.min(left, window.innerWidth - tooltip.width - padding))
top = Math.max(padding, Math.min(top, window.innerHeight - tooltip.height - padding))
setCoords({ top, left })
}
// open true (paint )
useLayoutEffect(() => {
if (open) updatePosition()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
useEffect(() => {
if (!open) return
const handler = () => updatePosition()
window.addEventListener('scroll', handler, true)
window.addEventListener('resize', handler)
return () => {
window.removeEventListener('scroll', handler, true)
window.removeEventListener('resize', handler)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const handleEnter = () => {
timerRef.current = setTimeout(() => setOpen(true), delay)
}
const handleLeave = () => {
clearTimeout(timerRef.current)
setOpen(false)
setCoords(null)
}
if (!text) return children
return (
<>
<span
ref={triggerRef}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onFocus={handleEnter}
onBlur={handleLeave}
className="inline-block"
>
{children}
</span>
{open && createPortal(
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: coords?.top ?? 0,
left: coords?.left ?? 0,
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 border text-xs shadow-lg whitespace-nowrap"
>
{text}
</div>,
document.body
)}
</>
)
}

View file

@ -1,8 +1,8 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { api } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { api } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
/**
* 캐릭터 입력 input 아래 뜨는 드롭다운

View file

@ -15,7 +15,7 @@ export default function Checkbox({ checked, onChange, disabled, className = '',
tabIndex={tabIndex}
onClick={(e) => { e.stopPropagation(); !disabled && onChange?.(!checked) }}
className={`${sizeCls} shrink-0 rounded-md border-2 flex items-center justify-center ${
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'
disabled ? 'opacity-40' : 'cursor-pointer'
} ${className}`}
style={checked ? {
borderColor: 'var(--accent)',

View file

@ -135,7 +135,7 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
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-[var(--row-hover-bg)] disabled:opacity-30 disabled:cursor-not-allowed"
className="p-1.5 rounded hover:bg-[var(--row-hover-bg)] disabled:opacity-30"
style={{ color: 'var(--text-muted)' }}
>
<ChevronIcon dir="left" size={18} />

View file

@ -0,0 +1,31 @@
/**
* 관리자 공용 필드 래퍼
* <FormField label="제목" required hint="설명" error={errors.title}>
* <input ... />
* </FormField>
*/
export default function FormField({ 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" style={{ color: 'var(--text-emphasis)' }}>
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
</label>
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
</div>
{children}
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
</div>
)
}
/**
* 관리자 공용 input 스타일
*/
export const formInputClass = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
export const formInputStyle = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}

View file

@ -0,0 +1,158 @@
import { useEffect, useRef, useState, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom'
const DELAY_DEFAULT = 200
/**
* 루트에 마운트되는 전역 툴팁.
* document에 이벤트 위임으로 [title] 또는 [data-tooltip] 가진 요소를 감지.
*
* 사용 측은 그냥 `title="설명"` 붙이면 :
* <button title="삭제">×</button>
*
* 커스텀 옵션:
* data-tooltip="..." title 대신 사용 (native tooltip 피하고 싶을 )
* data-tooltip-placement="top" top | bottom | left | right (기본 top)
* data-tooltip-delay="300" ms (기본 200)
*/
export default function GlobalTooltip() {
const [state, setState] = useState({ open: false, text: '', placement: 'top', coords: null })
const triggerRef = useRef(null)
const tooltipRef = useRef(null)
const timerRef = useRef(null)
const titleMap = useRef(new WeakMap())
useEffect(() => {
function restoreTitle() {
const el = triggerRef.current
if (el && titleMap.current.has(el)) {
const prev = titleMap.current.get(el)
el.setAttribute('title', prev)
titleMap.current.delete(el)
}
}
function hide() {
clearTimeout(timerRef.current)
restoreTitle()
triggerRef.current = null
setState((s) => (s.open ? { ...s, open: false } : s))
}
function showFor(target) {
const nativeTitle = target.getAttribute('title')
const dataTooltip = target.getAttribute('data-tooltip')
const text = nativeTitle || dataTooltip
if (!text) return
if (nativeTitle && !titleMap.current.has(target)) {
titleMap.current.set(target, nativeTitle)
target.removeAttribute('title')
}
const placement = target.getAttribute('data-tooltip-placement') || 'top'
const delay = Number(target.getAttribute('data-tooltip-delay')) || DELAY_DEFAULT
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
triggerRef.current = target
setState({ open: true, text, placement, coords: null })
}, delay)
}
function handleOver(e) {
const target = e.target.closest?.('[title], [data-tooltip]')
if (!target) return
if (triggerRef.current === target) return
//
if (triggerRef.current && triggerRef.current !== target) {
restoreTitle()
triggerRef.current = null
}
showFor(target)
}
function handleOut(e) {
if (!triggerRef.current) return
const rt = e.relatedTarget
if (rt && triggerRef.current.contains(rt)) return
hide()
}
function handleDown() { hide() }
function handleKey(e) { if (e.key === 'Escape') hide() }
document.addEventListener('mouseover', handleOver, true)
document.addEventListener('mouseout', handleOut, true)
document.addEventListener('focusin', handleOver, true)
document.addEventListener('focusout', handleOut, true)
document.addEventListener('mousedown', handleDown, true)
document.addEventListener('keydown', handleKey, true)
window.addEventListener('scroll', hide, true)
window.addEventListener('resize', hide)
return () => {
document.removeEventListener('mouseover', handleOver, true)
document.removeEventListener('mouseout', handleOut, true)
document.removeEventListener('focusin', handleOver, true)
document.removeEventListener('focusout', handleOut, true)
document.removeEventListener('mousedown', handleDown, true)
document.removeEventListener('keydown', handleKey, true)
window.removeEventListener('scroll', hide, true)
window.removeEventListener('resize', hide)
clearTimeout(timerRef.current)
restoreTitle()
}
}, [])
// open
useLayoutEffect(() => {
if (!state.open || !triggerRef.current || !tooltipRef.current) return
const trigger = triggerRef.current.getBoundingClientRect()
const tooltip = tooltipRef.current.getBoundingClientRect()
const gap = 6
let top, left
switch (state.placement) {
case 'bottom':
top = trigger.bottom + gap
left = trigger.left + trigger.width / 2 - tooltip.width / 2
break
case 'left':
top = trigger.top + trigger.height / 2 - tooltip.height / 2
left = trigger.left - tooltip.width - gap
break
case 'right':
top = trigger.top + trigger.height / 2 - tooltip.height / 2
left = trigger.right + gap
break
case 'top':
default:
top = trigger.top - tooltip.height - gap
left = trigger.left + trigger.width / 2 - tooltip.width / 2
}
const padding = 4
left = Math.max(padding, Math.min(left, window.innerWidth - tooltip.width - padding))
top = Math.max(padding, Math.min(top, window.innerHeight - tooltip.height - padding))
setState((s) => ({ ...s, coords: { top, left } }))
}, [state.open, state.text, state.placement])
if (!state.open) return null
return createPortal(
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: state.coords?.top ?? 0,
left: state.coords?.left ?? 0,
zIndex: 9999,
opacity: state.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 border text-xs shadow-lg whitespace-nowrap"
>
{state.text}
</div>,
document.body,
)
}

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuthStore } from '../stores/auth'
import { api } from '../api/client'
import { useAuthStore } from '../../stores/auth'
import { api } from '../../api/client'
export default function LoginDialog({ open, onClose }) {
const apiKey = useAuthStore((s) => s.apiKey)

View file

@ -0,0 +1,53 @@
import { motion, AnimatePresence } from 'framer-motion'
/**
* 관리자 페이지에서 쓰는 일반 모달 래퍼
* - 열기/닫기 애니메이션 포함
* - 뒷배경 클릭으로는 닫히지 않음 (× 버튼만)
*/
export default function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
return (
<AnimatePresence>
{open && (
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
style={{ background: 'var(--dialog-backdrop)' }}
>
<motion.div
key="dialog"
initial={{ opacity: 0, scale: 0.94, y: 8 }}
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 ${maxWidth} rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col`}
style={{
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
borderColor: 'var(--dialog-border)',
}}
>
<div
className="px-6 py-4 border-b flex items-center justify-between shrink-0"
style={{ borderColor: 'var(--panel-border)' }}
>
<h3 className="font-semibold" style={{ color: 'var(--text-strong)' }}>{title}</h3>
<button
onClick={onClose}
className="text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center"
style={{ color: 'var(--text-dim)' }}
aria-label="닫기"
>
×
</button>
</div>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View file

@ -0,0 +1,22 @@
/**
* 기존 호환용 래퍼. 실제 툴팁 표시는 GlobalTooltip 담당.
*
* <Tooltip text="설명" placement="top" delay={200}>
* <button>+</button>
* </Tooltip>
*
* 코드는 그냥 `title="..."` 직접 써도 .
*/
export default function Tooltip({ text, children, placement = 'top', delay = 200 }) {
if (!text) return children
return (
<span
title={text}
data-tooltip-placement={placement}
data-tooltip-delay={delay}
className="inline-block"
>
{children}
</span>
)
}

View file

@ -1,11 +1,11 @@
import { createContext, useContext, useState, useEffect } from 'react'
import { Outlet, Link, useLocation, useMatch } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import { api } from '../../api/client'
import Footer from './Footer'
import LoginDialog from './LoginDialog'
import { useThemeStore } from '../stores/theme'
import { useAuthStore } from '../stores/auth'
import LoginDialog from '../common/LoginDialog'
import { useThemeStore } from '../../stores/theme'
import { useAuthStore } from '../../stores/auth'
const SITE_NAME = '메이플스토리 유틸리티'

View file

@ -0,0 +1,165 @@
import { memo, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { fmtMD, fmtYMD, dayBadge } from './config'
function CardItem({ item, cfg }) {
const badge = dayBadge(item, cfg)
const start = item[cfg.dateStartKey]
const end = item[cfg.dateEndKey]
const startMD = fmtMD(start || item.date)
const endMD = fmtMD(end || item.date)
const dateText = (item.ongoing_flag === 'true' || item.ongoing_flag === true)
? '상시판매'
: start || end
? (startMD === endMD ? startMD : `${startMD} ~ ${endMD}`)
: fmtYMD(item.date)
const badgeBg = {
emerald: 'var(--badge-emerald-bg)',
amber: 'var(--badge-amber-bg)',
gray: 'var(--badge-gray-bg)',
}[badge?.tone]
return (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="group relative block rounded-xl overflow-hidden border"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div
className="aspect-[2/1] overflow-hidden"
style={{ background: 'var(--thumb-bg)' }}
>
{item.thumbnail_url ? (
<img
src={item.thumbnail_url}
alt=""
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="text-xs tabular-nums" style={{ color: 'var(--text-dim)' }}>
{dateText}
</div>
</div>
</a>
)
}
function CarouselSection({ cfg, items, isMaintenance, isLoading }) {
const [page, setPage] = useState(0)
const pages = Math.max(1, Math.ceil(items.length / cfg.pageSize))
const clamped = Math.min(page, pages - 1)
const slice = items.slice(clamped * cfg.pageSize, (clamped + 1) * cfg.pageSize)
const navBtn = "w-7 h-7 rounded-md border flex items-center justify-center border-[var(--btn-border)] bg-[var(--btn-bg)] hover:bg-[var(--btn-bg-hover)] hover:border-[var(--btn-border-hover)] disabled:opacity-30 disabled:hover:bg-[var(--btn-bg)] disabled:hover:border-[var(--btn-border)]"
return (
<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
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={clamped === 0}
className={navBtn}
aria-label="이전"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7.5 3L4.5 6L7.5 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<span className="tabular-nums 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="relative overflow-x-clip pb-2">
{isLoading ? (
<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>
) : 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>
) : (
<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"
>
{slice.map((it) => <CardItem key={it.notice_id} item={it} cfg={cfg} />)}
</motion.div>
</AnimatePresence>
)}
</div>
</section>
)
}
export default memo(CarouselSection)

View file

@ -0,0 +1,140 @@
import { memo, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { fmtYMD, isRecent } from './config'
function TextListSection({ cfg, items, isMaintenance, isLoading }) {
const [page, setPage] = useState(0)
const pages = Math.max(1, Math.ceil(items.length / cfg.pageSize))
const clamped = Math.min(page, pages - 1)
const slice = items.slice(clamped * cfg.pageSize, (clamped + 1) * cfg.pageSize)
return (
<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] }}
>
{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>
)
}
export default memo(TextListSection)

View file

@ -0,0 +1,62 @@
export const SECTIONS = {
notice: { label: '메이플스토리 공지사항', dataKey: 'notice', pageSize: 5, kind: 'text' },
update: { label: '메이플스토리 업데이트', dataKey: 'update_notice', pageSize: 5, kind: 'text' },
event: {
label: '진행 중인 이벤트',
dataKey: 'event_notice',
pageSize: 3,
kind: 'card',
dateStartKey: 'date_event_start',
dateEndKey: 'date_event_end',
filterOngoing: true,
},
cashshop: {
label: '캐시샵 공지',
dataKey: 'cashshop_notice',
pageSize: 3,
kind: 'card',
dateStartKey: 'date_sale_start',
dateEndKey: 'date_sale_end',
filterOngoing: true,
},
}
export function fmtMD(iso) {
if (!iso) return ''
const d = new Date(iso)
return `${d.getMonth() + 1}/${d.getDate()}`
}
export function fmtYMD(iso) {
if (!iso) return ''
const d = new Date(iso)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function isRecent(iso, days = 3) {
if (!iso) return false
return (Date.now() - new Date(iso).getTime()) / 86400000 < days
}
export function isOngoing(item, cfg) {
if (!cfg.filterOngoing) return true
const end = item[cfg.dateEndKey]
if (end) return new Date(end) > new Date()
if (item.ongoing_flag !== undefined) return item.ongoing_flag === 'true' || item.ongoing_flag === true
return false
}
export function dayBadge(item, cfg) {
const now = Date.now()
const start = item[cfg.dateStartKey] ? new Date(item[cfg.dateStartKey]).getTime() : null
const end = item[cfg.dateEndKey] ? new Date(item[cfg.dateEndKey]).getTime() : null
if (start && start > now) {
const d = Math.ceil((start - now) / 86400000)
return { label: `시작 ${d}일 전`, tone: 'emerald' }
}
if (end) {
const d = Math.ceil((end - now) / 86400000)
if (d <= 0) return null
return { label: `종료 ${d}일 전`, tone: 'amber' }
}
if (item.ongoing_flag === 'true' || item.ongoing_flag === true) {
return { label: '상시판매', tone: 'gray' }
}
return null
}

View file

@ -0,0 +1,38 @@
import { useQueries } from '@tanstack/react-query'
import { api } from '../../../api/client'
import { SECTIONS, isOngoing } from './config'
import TextListSection from './TextListSection'
import CarouselSection from './CarouselSection'
export default function NoticeWidget() {
const queries = useQueries({
queries: Object.keys(SECTIONS).map((key) => ({
queryKey: ['notices', key],
queryFn: () => api(`/api/notices?type=${key}`),
staleTime: 5 * 60 * 1000,
retry: (n, err) => (err?.maintenance ? false : n < 1),
})),
})
const byKey = Object.keys(SECTIONS).reduce((acc, key, i) => {
const q = queries[i]
const cfg = SECTIONS[key]
const list = q.data?.[cfg.dataKey] || []
const items = cfg.filterOngoing ? list.filter((n) => isOngoing(n, cfg)) : list
acc[key] = { items, isLoading: q.isLoading, isMaintenance: !!q.error?.maintenance }
return acc
}, {})
return (
<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

@ -0,0 +1,158 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import { api } from '../../api/client'
function SundayMapleDialog({ data, onClose }) {
//
useEffect(() => {
const prevBody = document.body.style.overflow
const prevHtml = document.documentElement.style.overflow
document.body.style.overflow = 'hidden'
document.documentElement.style.overflow = 'hidden'
return () => {
document.body.style.overflow = prevBody
document.documentElement.style.overflow = prevHtml
}
}, [])
const iconBtn = "w-8 h-8 rounded-lg backdrop-blur-sm border flex items-center justify-center hover:bg-[var(--row-hover-bg)]"
const iconBtnStyle = {
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}
return (
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="fixed top-0 left-0 z-50 flex items-center justify-center p-4 backdrop-blur-md"
style={{
background: 'var(--dialog-backdrop)',
width: '100vw',
height: '100dvh',
}}
onClick={onClose}
>
<motion.div
key="dialog"
initial={{ opacity: 0, scale: 0.94, y: 8 }}
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="relative w-full max-w-[640px] max-h-[90vh] flex flex-col rounded-2xl border shadow-2xl overflow-hidden"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
}}
onClick={(e) => e.stopPropagation()}
>
<div className="absolute top-3 right-3 z-10 flex items-center gap-2">
{data.event_post_url && (
<a
href={data.event_post_url}
target="_blank"
rel="noopener noreferrer"
className={iconBtn}
style={iconBtnStyle}
aria-label="공식 공지로 이동"
title="공식 공지로 이동"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M10 5H5a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2v-5M14 3h7m0 0v7m0-7L10 14"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
)}
<button
onClick={onClose}
className={`${iconBtn} text-xl leading-none`}
style={iconBtnStyle}
aria-label="닫기"
>
×
</button>
</div>
<OverlayScrollbarsComponent
className="flex-1 min-h-0"
style={{ overscrollBehavior: 'contain' }}
options={{
scrollbars: { theme: 'os-theme-maple os-theme-dark', autoHide: 'leave', autoHideDelay: 800 },
overflow: { x: 'hidden', y: 'scroll' },
}}
defer
>
<img src={data.image_url} alt="썬데이 메이플" className="w-full block" />
</OverlayScrollbarsComponent>
</motion.div>
</motion.div>
)
}
export default function SundayMapleBanner() {
const [open, setOpen] = useState(false)
const { data } = useQuery({
queryKey: ['sunday-maple', 'current'],
queryFn: () => api('/api/sunday-maple/current').catch(() => ({ available: false })),
staleTime: 10 * 60 * 1000,
})
const iconName = data?.variant === 'special' ? '스페셜 썬데이 메이플' : '썬데이 메이플'
const { data: iconData } = useQuery({
queryKey: ['image', iconName],
queryFn: () => api(`/api/images/${encodeURIComponent(iconName)}`).catch(() => null),
enabled: !!data?.available,
staleTime: Infinity,
})
if (!data?.available) return null
const label = data.variant === 'special' ? '스페셜 썬데이 메이플' : '썬데이 메이플'
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="w-full rounded-2xl border p-4 flex items-center gap-4 transition-transform duration-300 hover:scale-[1.01]"
style={{
background: 'var(--selected-bg)',
borderColor: 'var(--selected-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div
className="shrink-0 w-14 h-14 rounded-xl flex items-center justify-center overflow-hidden"
style={{ background: 'var(--panel-bg)' }}
>
{iconData?.url ? (
<img src={iconData.url} alt={label} className="w-full h-full object-contain" />
) : (
<span className="text-2xl">📅</span>
)}
</div>
<div className="flex-1 min-w-0 text-left">
<div className="font-semibold text-base" style={{ color: 'var(--accent-bright)' }}>
이번 {label}
</div>
<div className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
일요일에 받을 있는 혜택을 확인하세요
</div>
</div>
<div className="shrink-0 text-sm font-medium" style={{ color: 'var(--accent-bright)' }}>
보기
</div>
</button>
<AnimatePresence>
{open && <SundayMapleDialog data={data} onClose={() => setOpen(false)} />}
</AnimatePresence>
</>
)
}

View file

View file

@ -1,652 +0,0 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../api/client'
import ConfirmDialog from '../../components/ConfirmDialog'
import { useAuthStore } from '../../stores/auth'
/* ── 공용 모달 ── */
function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
style={{ background: 'var(--dialog-backdrop)' }}
onClick={onClose}
>
<div
className={`w-full ${maxWidth} rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col`}
style={{
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
borderColor: 'var(--dialog-border)',
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="px-6 py-4 border-b flex items-center justify-between shrink-0"
style={{ borderColor: 'var(--panel-border)' }}
>
<h3 className="font-semibold" style={{ color: 'var(--text-strong)' }}>{title}</h3>
<button
onClick={onClose}
className="text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center"
style={{ color: 'var(--text-dim)' }}
>
×
</button>
</div>
{children}
</div>
</div>
)
}
/* ── 업로드 모달 (다중 지원) ── */
function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
const [items, setItems] = useState([])
const [dragOver, setDragOver] = useState(false)
useEffect(() => {
if (!open) setItems([])
}, [open])
const addFiles = (fileList) => {
const newItems = []
Array.from(fileList).forEach((file) => {
if (!file.type.startsWith('image/')) return
const id = `${Date.now()}-${Math.random()}`
const reader = new FileReader()
reader.onload = (e) => {
setItems((prev) => prev.map((it) => it.id === id ? { ...it, preview: e.target.result } : it))
}
reader.readAsDataURL(file)
newItems.push({
id,
file,
name: file.name.replace(/\.[^.]+$/, ''),
preview: null,
})
})
setItems((prev) => [...prev, ...newItems])
}
const updateName = (id, name) => {
setItems((prev) => prev.map((it) => it.id === id ? { ...it, name } : it))
}
const removeItem = (id) => {
setItems((prev) => prev.filter((it) => it.id !== id))
}
const trimmedNames = items.map((it) => it.name.trim())
const hasEmpty = trimmedNames.some((n) => !n)
const hasDupExisting = trimmedNames.some((n) => existingNames.has(n))
const hasDupInList = trimmedNames.some((n, i) => trimmedNames.indexOf(n) !== i)
const canSubmit = items.length > 0 && !hasEmpty && !hasDupExisting && !hasDupInList
const handleSubmit = async (e) => {
e.preventDefault()
if (!canSubmit) return
await onUpload(items)
}
return (
<Modal open={open} onClose={onClose} title={`이미지 업로드${items.length > 0 ? ` (${items.length})` : ''}`} maxWidth="max-w-2xl">
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="p-6 space-y-4 overflow-y-auto flex-1">
{/* 파일 추가 영역 */}
<label
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
addFiles(e.dataTransfer.files)
}}
className="relative rounded-xl border-2 border-dashed cursor-pointer min-h-[120px] flex flex-col items-center justify-center"
style={dragOver ? {
borderColor: 'var(--selected-border)',
background: 'var(--selected-bg)',
} : {
borderColor: 'var(--dashed-border)',
background: 'var(--skeleton-bg)',
}}
>
<div className="text-2xl mb-1 opacity-50">📥</div>
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>클릭하거나 이미지를 끌어다 놓으세요</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-dim)' }}>여러 선택 가능</p>
<input
type="file"
accept="image/*"
multiple
onChange={(e) => { addFiles(e.target.files); e.target.value = '' }}
className="hidden"
/>
</label>
{/* 선택된 파일 리스트 */}
{items.length > 0 && (
<div className="space-y-2">
{items.map((item, idx) => {
const trimmed = item.name.trim()
const dupExisting = trimmed && existingNames.has(trimmed)
const dupInList = trimmed && items.some((it, j) => j !== idx && it.name.trim() === trimmed)
const empty = !trimmed
const errorMsg = empty ? '이름을 입력해주세요'
: dupExisting ? '이미 존재하는 이름입니다'
: dupInList ? '같은 이름이 중복됩니다'
: null
return (
<div
key={item.id}
className="flex items-start gap-3 rounded-lg border p-2"
style={{
background: 'var(--surface-3)',
borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--panel-border)',
}}
>
<div
className="w-12 h-12 rounded flex items-center justify-center overflow-hidden shrink-0"
style={{ background: 'var(--surface-nested)' }}
>
{item.preview ? (
<img src={item.preview} alt="" className="w-full h-full object-contain" />
) : (
<div className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
)}
</div>
<div className="flex-1 min-w-0 space-y-0.5">
<input
type="text"
value={item.name}
onChange={(e) => updateName(item.id, e.target.value)}
className="w-full rounded border px-2 py-1.5 text-sm outline-none"
style={{
background: 'var(--input-bg)',
borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--input-border)',
color: 'var(--text-strong)',
}}
/>
{errorMsg && (
<div className="text-[11px] px-0.5" style={{ color: 'var(--danger-text)' }}>{errorMsg}</div>
)}
</div>
<button
type="button"
onClick={() => removeItem(item.id)}
className="w-7 h-7 rounded shrink-0 hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)]"
style={{ color: 'var(--text-dim)' }}
>
×
</button>
</div>
)
})}
</div>
)}
</div>
{/* 버튼 */}
<div
className="flex gap-2 px-6 py-4 border-t shrink-0"
style={{ borderColor: 'var(--panel-border)' }}
>
<button
type="button"
onClick={onClose}
className="flex-1 rounded-lg border px-4 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
취소
</button>
<button
type="submit"
disabled={!canSubmit || uploading}
className="flex-1 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
{uploading ? '업로드 중...' : `${items.length > 0 ? `${items.length}` : ''}업로드`}
</button>
</div>
</form>
</Modal>
)
}
/* ── 이미지 카드 ── */
function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) {
return (
<div
onClick={() => selectMode && onToggle(image.id)}
className={`group relative rounded-xl border overflow-hidden ${selectMode ? 'cursor-pointer' : ''}`}
style={{
borderColor: selected ? 'var(--selected-border)' : 'var(--panel-border)',
background: selected ? 'var(--selected-bg)' : 'var(--panel-bg)',
boxShadow: selected ? '0 0 0 2px var(--ring-info)' : 'var(--panel-shadow)',
}}
>
{selectMode && (
<div
className="absolute top-2 left-2 z-10 w-5 h-5 rounded border-2 flex items-center justify-center"
style={selected ? {
borderColor: 'var(--accent)',
background: 'var(--accent)',
} : {
borderColor: 'var(--panel-border)',
background: 'var(--surface-3)',
}}
>
{selected && <span className="text-xs" style={{ color: 'var(--btn-primary-text)' }}></span>}
</div>
)}
<div
className="aspect-square flex items-center justify-center p-4 relative"
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
>
<img
src={image.url}
alt={image.name}
className="w-full h-full object-contain"
style={{ imageRendering: 'pixelated' }}
/>
{!selectMode && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={(e) => { e.stopPropagation(); onCopyUrl(image) }}
className="w-7 h-7 rounded-md backdrop-blur-sm border text-xs flex items-center justify-center hover:bg-[var(--selected-bg)] hover:border-[var(--selected-border)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
title="URL 복사"
>
{copied ? '✓' : '⧉'}
</button>
</div>
)}
</div>
<div
className="px-3 py-2 border-t"
style={{ borderColor: 'var(--panel-border)' }}
>
<div className="text-sm font-medium truncate">{image.name}</div>
</div>
</div>
)
}
/* ── 페이지네이션 ── */
function Pagination({ page, totalPages, onChange }) {
if (totalPages <= 1) return null
const pages = []
const maxButtons = 7
let start = Math.max(1, page - Math.floor(maxButtons / 2))
let end = Math.min(totalPages, start + maxButtons - 1)
if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1)
for (let i = start; i <= end; i++) pages.push(i)
const baseBtn = "min-w-9 h-9 px-3 rounded-lg text-sm flex items-center justify-center border hover:bg-[var(--btn-bg-hover)]"
const btnStyle = {
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}
return (
<div className="flex items-center justify-center gap-1 pt-2">
<button
onClick={() => onChange(page - 1)}
disabled={page === 1}
className={`${baseBtn} disabled:opacity-30 disabled:cursor-not-allowed`}
style={btnStyle}
>
</button>
{start > 1 && (
<>
<button onClick={() => onChange(1)} className={baseBtn} style={btnStyle}>1</button>
{start > 2 && <span className="px-1" style={{ color: 'var(--text-dim)' }}></span>}
</>
)}
{pages.map((p) => {
const active = p === page
return (
<button
key={p}
onClick={() => onChange(p)}
className={`${baseBtn} ${active ? 'font-medium' : ''}`}
style={active ? {
background: 'var(--selected-bg)',
borderColor: 'var(--selected-border)',
color: 'var(--accent-bright)',
} : btnStyle}
>
{p}
</button>
)
})}
{end < totalPages && (
<>
{end < totalPages - 1 && <span className="px-1" style={{ color: 'var(--text-dim)' }}></span>}
<button onClick={() => onChange(totalPages)} className={baseBtn} style={btnStyle}>{totalPages}</button>
</>
)}
<button
onClick={() => onChange(page + 1)}
disabled={page === totalPages}
className={`${baseBtn} disabled:opacity-30 disabled:cursor-not-allowed`}
style={btnStyle}
>
</button>
</div>
)
}
const PAGE_SIZE = 24
/* ── 메인 ── */
export default function AdminImages() {
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [uploadOpen, setUploadOpen] = useState(false)
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState(new Set())
const [confirmDelete, setConfirmDelete] = useState(null)
const [copiedId, setCopiedId] = useState(null)
useEffect(() => {
const t = setTimeout(() => {
setDebouncedSearch(search)
setPage(1)
}, 300)
return () => clearTimeout(t)
}, [search])
const { data: imagesData, isLoading } = useQuery({
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
queryFn: async () => {
const params = new URLSearchParams({
page,
limit: PAGE_SIZE,
...(debouncedSearch && { search: debouncedSearch }),
})
return api(`/api/admin/images?${params}`)
},
placeholderData: (prev) => prev,
})
const images = imagesData?.items || []
const totalPages = imagesData?.total_pages || 1
const { data: allNamesArray = [] } = useQuery({
queryKey: ['admin', 'images', 'names'],
queryFn: () => api('/api/admin/images/names'),
})
const allNames = new Set(allNamesArray)
const invalidateImages = () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'images'] })
}
const uploadMutation = useMutation({
mutationFn: async (items) => {
const formData = new FormData()
items.forEach((it) => {
formData.append('files', it.file)
formData.append('names', it.name.trim())
})
const adminKey = useAuthStore.getState().apiKey
const res = await fetch('/api/admin/images', {
method: 'POST',
headers: { 'x-admin-key': adminKey },
body: formData,
})
const result = await res.json()
if (!res.ok) throw new Error(result.error || '업로드 실패')
return result
},
onSuccess: (result) => {
if (result.errors?.length > 0) {
alert(`일부 업로드 실패:\n${result.errors.map((e) => `- ${e.name}: ${e.error}`).join('\n')}`)
}
setUploadOpen(false)
invalidateImages()
},
onError: (err) => alert(err.message),
})
const toggleSelect = (id) => {
setSelectedIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const toggleSelectMode = () => {
setSelectMode((prev) => !prev)
setSelectedIds(new Set())
}
const selectAll = () => {
if (selectedIds.size === images.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(images.map((img) => img.id)))
}
}
const requestDelete = () => {
const items = images.filter((img) => selectedIds.has(img.id))
setConfirmDelete({
ids: items.map((i) => i.id),
names: items.map((i) => i.name),
})
}
const deleteMutation = useMutation({
mutationFn: (ids) => api('/api/admin/images/delete', { method: 'POST', body: { ids } }),
onSuccess: () => {
setConfirmDelete(null)
setSelectedIds(new Set())
setSelectMode(false)
invalidateImages()
},
onError: (err) => alert(err.message),
})
const copyUrl = (image) => {
navigator.clipboard.writeText(image.url)
setCopiedId(image.id)
setTimeout(() => setCopiedId(null), 1500)
}
return (
<div className="space-y-6 max-w-5xl mx-auto pt-6">
<div className="flex items-end justify-between gap-4 flex-wrap">
<div>
<h2 className="text-lg font-medium">이미지 관리</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>공용 이미지를 업로드하고 관리합니다</p>
</div>
<div className="flex items-center gap-2">
{selectMode ? (
<>
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>{selectedIds.size} 선택</span>
<button
onClick={selectAll}
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
{selectedIds.size === images.length && images.length > 0 ? '전체 해제' : '전체 선택'}
</button>
<button
onClick={requestDelete}
disabled={selectedIds.size === 0}
className="rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--btn-danger-bg-hover)]"
style={{
background: 'var(--btn-danger-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-danger-shadow)',
}}
>
삭제
</button>
<button
onClick={toggleSelectMode}
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
완료
</button>
</>
) : (
<>
{images.length > 0 && (
<button
onClick={toggleSelectMode}
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--danger-bg-hover)]"
style={{
borderColor: 'var(--icon-danger-border)',
color: 'var(--danger-text)',
}}
>
삭제
</button>
)}
<button
onClick={() => setUploadOpen(true)}
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
<span className="text-base leading-none">+</span>
이미지 업로드
</button>
</>
)}
</div>
</div>
{/* 검색 */}
{images.length > 0 && (
<div className="relative">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="이미지 이름으로 검색..."
className="w-full rounded-lg border pl-10 pr-4 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)',
}}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--input-icon)' }}>🔍</span>
</div>
)}
{/* 이미지 그리드 */}
{isLoading ? (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-xl animate-pulse"
style={{ background: 'var(--skeleton-bg)' }}
/>
))}
</div>
) : images.length === 0 ? (
<div
className="rounded-2xl border border-dashed p-16 text-center"
style={{
borderColor: 'var(--dashed-border)',
background: 'var(--skeleton-bg)',
}}
>
<div className="text-5xl mb-3 opacity-30">🖼</div>
<p className="mb-4" style={{ color: 'var(--text-muted)' }}>
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
</p>
{!debouncedSearch && (
<button
onClick={() => setUploadOpen(true)}
className="text-sm hover:text-[var(--accent-hover-text)]"
style={{ color: 'var(--accent)' }}
>
이미지 업로드하기
</button>
)}
</div>
) : (
<>
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{images.map((image) => (
<ImageCard
key={image.id}
image={image}
selected={selectedIds.has(image.id)}
selectMode={selectMode}
onToggle={toggleSelect}
onCopyUrl={copyUrl}
copied={copiedId === image.id}
/>
))}
</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
<UploadModal
open={uploadOpen}
onClose={() => setUploadOpen(false)}
onUpload={(items) => uploadMutation.mutate(items)}
uploading={uploadMutation.isPending}
existingNames={allNames}
/>
<ConfirmDialog
open={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
onConfirm={() => deleteMutation.mutate(confirmDelete.ids)}
title="이미지 삭제"
description={confirmDelete ? `${confirmDelete.ids.length}개의 이미지를 삭제하시겠습니까?\n\n${confirmDelete.names.slice(0, 5).map((n) => `· ${n}`).join('\n')}${confirmDelete.names.length > 5 ? `\n· 외 ${confirmDelete.names.length - 5}` : ''}\n\n이 작업은 되돌릴 수 없습니다.` : ''}
confirmText="삭제"
destructive
loading={deleteMutation.isPending}
/>
</div>
)
}

View file

@ -1,197 +0,0 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '../../../api/client'
const PAGE_SIZE = 24
export default function ImagePicker({ open, onClose, onSelect, currentImageId }) {
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
useEffect(() => {
const t = setTimeout(() => {
setDebouncedSearch(search)
setPage(1)
}, 300)
return () => clearTimeout(t)
}, [search])
useEffect(() => {
if (!open) {
setSearch('')
setDebouncedSearch('')
setPage(1)
}
}, [open])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
queryFn: () => {
const params = new URLSearchParams({
page,
limit: PAGE_SIZE,
...(debouncedSearch && { search: debouncedSearch }),
})
return api(`/api/admin/images?${params}`)
},
enabled: open,
placeholderData: (prev) => prev,
})
const images = data?.items || []
const totalPages = data?.total_pages || 1
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
style={{ background: 'var(--dialog-backdrop)' }}
onClick={onClose}
>
<div
className="w-full max-w-3xl rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col"
style={{
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
borderColor: 'var(--dialog-border)',
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="px-6 py-4 border-b flex items-center justify-between shrink-0"
style={{ borderColor: 'var(--panel-border)' }}
>
<h3 className="font-semibold" style={{ color: 'var(--text-strong)' }}>이미지 선택</h3>
<button
onClick={onClose}
className="text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center"
style={{ color: 'var(--text-dim)' }}
>
×
</button>
</div>
{/* 검색 */}
<div className="px-6 pt-4 shrink-0">
<div className="relative">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="이미지 이름으로 검색..."
className="w-full rounded-lg border pl-10 pr-4 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)',
}}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--input-icon)' }}>🔍</span>
</div>
</div>
{/* 이미지 그리드 */}
<div className="px-6 py-4 overflow-y-auto flex-1">
{isLoading ? (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-lg animate-pulse"
style={{ background: 'var(--skeleton-bg)' }}
/>
))}
</div>
) : images.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-dim)' }}>
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
</div>
) : (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{images.map((image) => {
const isSelected = currentImageId === image.id
return (
<button
key={image.id}
type="button"
onClick={() => { onSelect(image); onClose() }}
className="group rounded-lg border overflow-hidden"
style={{
borderColor: isSelected ? 'var(--selected-border)' : 'var(--panel-border)',
boxShadow: isSelected ? '0 0 0 2px var(--ring-info)' : undefined,
}}
title={image.name}
>
<div
className="aspect-square flex items-center justify-center p-3"
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
>
<img src={image.url} alt={image.name} className="max-w-full max-h-full object-contain" />
</div>
<div
className="px-2 py-1.5 border-t"
style={{
borderColor: 'var(--panel-border)',
background: 'var(--surface-3)',
}}
>
<div className="text-xs truncate">{image.name}</div>
</div>
</button>
)
})}
</div>
)}
</div>
{/* 페이지네이션 + 액션 */}
<div
className="px-6 py-4 border-t flex items-center justify-between shrink-0 gap-3"
style={{ borderColor: 'var(--panel-border)' }}
>
{totalPages > 1 ? (
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
</button>
<span className="text-xs px-2" style={{ color: 'var(--text-muted)' }}>{page} / {totalPages}</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
</button>
</div>
) : <div />}
{currentImageId && (
<button
type="button"
onClick={() => { onSelect(null); onClose() }}
className="text-sm hover:text-[var(--danger-text-strong)]"
style={{ color: 'var(--danger-text)' }}
>
이미지 제거
</button>
)}
</div>
</div>
</div>
)
}

View file

@ -1,8 +1,8 @@
import { Suspense } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { getAdminComponent } from '../registry'
import { api } from '../../api/client'
import { getAdminComponent } from '../../registry'
import { api } from '../../../api/client'
export default function AdminFeaturePage() {
const { slug } = useParams()

View file

@ -1,6 +1,6 @@
import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../../api/client'
import { api } from '../../../api/client'
function MenuCard({ menu }) {
const navigate = useNavigate()

View file

@ -0,0 +1,298 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../../api/client'
import ConfirmDialog from '../../../components/common/ConfirmDialog'
import { useAuthStore } from '../../../stores/auth'
import ImageCard from './components/ImageCard'
import Pagination from './components/Pagination'
import UploadModal from './components/UploadModal'
const PAGE_SIZE = 24
export default function AdminImages() {
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [uploadOpen, setUploadOpen] = useState(false)
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState(new Set())
const [confirmDelete, setConfirmDelete] = useState(null)
const [copiedId, setCopiedId] = useState(null)
useEffect(() => {
const t = setTimeout(() => {
setDebouncedSearch(search)
setPage(1)
}, 300)
return () => clearTimeout(t)
}, [search])
const { data: imagesData, isLoading } = useQuery({
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
queryFn: async () => {
const params = new URLSearchParams({
page,
limit: PAGE_SIZE,
...(debouncedSearch && { search: debouncedSearch }),
})
return api(`/api/admin/images?${params}`)
},
placeholderData: (prev) => prev,
})
const images = imagesData?.items || []
const totalPages = imagesData?.total_pages || 1
const { data: allNamesArray = [] } = useQuery({
queryKey: ['admin', 'images', 'names'],
queryFn: () => api('/api/admin/images/names'),
})
const allNames = new Set(allNamesArray)
const invalidateImages = () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'images'] })
}
const uploadMutation = useMutation({
mutationFn: async (items) => {
const formData = new FormData()
items.forEach((it) => {
formData.append('files', it.file)
formData.append('names', it.name.trim())
})
const adminKey = useAuthStore.getState().apiKey
const res = await fetch('/api/admin/images', {
method: 'POST',
headers: { 'x-admin-key': adminKey },
body: formData,
})
const result = await res.json()
if (!res.ok) throw new Error(result.error || '업로드 실패')
return result
},
onSuccess: (result) => {
if (result.errors?.length > 0) {
alert(`일부 업로드 실패:\n${result.errors.map((e) => `- ${e.name}: ${e.error}`).join('\n')}`)
}
setUploadOpen(false)
invalidateImages()
},
onError: (err) => alert(err.message),
})
const toggleSelect = (id) => {
setSelectedIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const toggleSelectMode = () => {
setSelectMode((prev) => !prev)
setSelectedIds(new Set())
}
const selectAll = () => {
if (selectedIds.size === images.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(images.map((img) => img.id)))
}
}
const requestDelete = () => {
const items = images.filter((img) => selectedIds.has(img.id))
setConfirmDelete({
ids: items.map((i) => i.id),
names: items.map((i) => i.name),
})
}
const deleteMutation = useMutation({
mutationFn: (ids) => api('/api/admin/images/delete', { method: 'POST', body: { ids } }),
onSuccess: () => {
setConfirmDelete(null)
setSelectedIds(new Set())
setSelectMode(false)
invalidateImages()
},
onError: (err) => alert(err.message),
})
const copyUrl = (image) => {
navigator.clipboard.writeText(image.url)
setCopiedId(image.id)
setTimeout(() => setCopiedId(null), 1500)
}
return (
<div className="space-y-6 max-w-5xl mx-auto pt-6">
<div className="flex items-end justify-between gap-4 flex-wrap">
<div>
<h2 className="text-lg font-medium">이미지 관리</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>공용 이미지를 업로드하고 관리합니다</p>
</div>
<div className="flex items-center gap-2">
{selectMode ? (
<>
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>{selectedIds.size} 선택</span>
<button
onClick={selectAll}
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
{selectedIds.size === images.length && images.length > 0 ? '전체 해제' : '전체 선택'}
</button>
<button
onClick={requestDelete}
disabled={selectedIds.size === 0}
className="rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50 hover:bg-[var(--btn-danger-bg-hover)]"
style={{
background: 'var(--btn-danger-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-danger-shadow)',
}}
>
삭제
</button>
<button
onClick={toggleSelectMode}
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
완료
</button>
</>
) : (
<>
{images.length > 0 && (
<button
onClick={toggleSelectMode}
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--danger-bg-hover)]"
style={{
borderColor: 'var(--icon-danger-border)',
color: 'var(--danger-text)',
}}
>
삭제
</button>
)}
<button
onClick={() => setUploadOpen(true)}
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
<span className="text-base leading-none">+</span>
이미지 업로드
</button>
</>
)}
</div>
</div>
{/* 검색 */}
{images.length > 0 && (
<div className="relative">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="이미지 이름으로 검색..."
className="w-full rounded-lg border pl-10 pr-4 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)',
}}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--input-icon)' }}>🔍</span>
</div>
)}
{/* 이미지 그리드 */}
{isLoading ? (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-xl animate-pulse"
style={{ background: 'var(--skeleton-bg)' }}
/>
))}
</div>
) : images.length === 0 ? (
<div
className="rounded-2xl border border-dashed p-16 text-center"
style={{
borderColor: 'var(--dashed-border)',
background: 'var(--skeleton-bg)',
}}
>
<div className="text-5xl mb-3 opacity-30">🖼</div>
<p className="mb-4" style={{ color: 'var(--text-muted)' }}>
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
</p>
{!debouncedSearch && (
<button
onClick={() => setUploadOpen(true)}
className="text-sm hover:text-[var(--accent-hover-text)]"
style={{ color: 'var(--accent)' }}
>
이미지 업로드하기
</button>
)}
</div>
) : (
<>
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{images.map((image) => (
<ImageCard
key={image.id}
image={image}
selected={selectedIds.has(image.id)}
selectMode={selectMode}
onToggle={toggleSelect}
onCopyUrl={copyUrl}
copied={copiedId === image.id}
/>
))}
</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
<UploadModal
open={uploadOpen}
onClose={() => setUploadOpen(false)}
onUpload={(items) => uploadMutation.mutate(items)}
uploading={uploadMutation.isPending}
existingNames={allNames}
/>
<ConfirmDialog
open={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
onConfirm={() => deleteMutation.mutate(confirmDelete.ids)}
title="이미지 삭제"
description={confirmDelete ? `${confirmDelete.ids.length}개의 이미지를 삭제하시겠습니까?\n\n${confirmDelete.names.slice(0, 5).map((n) => `· ${n}`).join('\n')}${confirmDelete.names.length > 5 ? `\n· 외 ${confirmDelete.names.length - 5}` : ''}\n\n이 작업은 되돌릴 수 없습니다.` : ''}
confirmText="삭제"
destructive
loading={deleteMutation.isPending}
/>
</div>
)
}

View file

@ -1,7 +1,7 @@
import { Outlet, Navigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import { api } from '../../../api/client'
import { useAuthStore } from '../../../stores/auth'
export default function AdminLayout() {
const queryClient = useQueryClient()

View file

@ -1,31 +1,10 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../api/client'
import { api } from '../../../api/client'
import ImagePicker from './components/ImagePicker'
import ConfirmDialog from '../../components/ConfirmDialog'
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" style={{ color: 'var(--text-emphasis)' }}>
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
</label>
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
</div>
{children}
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
</div>
)
}
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
const inputStyle = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}
import ConfirmDialog from '../../../components/common/ConfirmDialog'
import FormField, { formInputClass, formInputStyle } from '../../../components/common/FormField'
export default function AdminMenuForm() {
const navigate = useNavigate()
@ -175,29 +154,29 @@ export default function AdminMenuForm() {
</div>
</div>
<Field label="제목" required error={errors.title}>
<FormField label="제목" required error={errors.title}>
<input
type="text"
value={form.title}
onChange={(e) => update({ title: e.target.value })}
placeholder="예: 주간 보스 수익 계산기"
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
/>
</Field>
</FormField>
<Field label="설명" hint="카드에 표시되는 부가 설명">
<FormField label="설명" hint="카드에 표시되는 부가 설명">
<input
type="text"
value={form.description}
onChange={(e) => update({ description: e.target.value })}
placeholder="예: 캐릭터별 보스 결정석 수익을 계산합니다"
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
/>
</Field>
</FormField>
<Field label="경로" required error={errors.slug}>
<FormField label="경로" required error={errors.slug}>
<div
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
style={{
@ -234,9 +213,9 @@ export default function AdminMenuForm() {
</code>
</div>
)}
</Field>
</FormField>
<Field label="아이콘 이미지" hint="선택사항">
<FormField label="아이콘 이미지" hint="선택사항">
<div className="flex items-center gap-3">
<button
type="button"
@ -271,7 +250,7 @@ export default function AdminMenuForm() {
)}
</div>
</div>
</Field>
</FormField>
<div className="flex items-center gap-2 pt-2">
{isEdit && (

View file

@ -0,0 +1,68 @@
import { memo } from 'react'
function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) {
return (
<div
onClick={() => selectMode && onToggle(image.id)}
className={`group relative rounded-xl border overflow-hidden ${selectMode ? 'cursor-pointer' : ''}`}
style={{
borderColor: selected ? 'var(--selected-border)' : 'var(--panel-border)',
background: selected ? 'var(--selected-bg)' : 'var(--panel-bg)',
boxShadow: selected ? '0 0 0 2px var(--ring-info)' : 'var(--panel-shadow)',
}}
>
{selectMode && (
<div
className="absolute top-2 left-2 z-10 w-5 h-5 rounded border-2 flex items-center justify-center"
style={selected ? {
borderColor: 'var(--accent)',
background: 'var(--accent)',
} : {
borderColor: 'var(--panel-border)',
background: 'var(--surface-3)',
}}
>
{selected && <span className="text-xs" style={{ color: 'var(--btn-primary-text)' }}></span>}
</div>
)}
<div
className="aspect-square flex items-center justify-center p-4 relative"
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
>
<img
src={image.url}
alt={image.name}
className="w-full h-full object-contain"
style={{ imageRendering: 'pixelated' }}
/>
{!selectMode && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={(e) => { e.stopPropagation(); onCopyUrl(image) }}
className="w-7 h-7 rounded-md backdrop-blur-sm border text-xs flex items-center justify-center hover:bg-[var(--selected-bg)] hover:border-[var(--selected-border)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
title="URL 복사"
>
{copied ? '✓' : '⧉'}
</button>
</div>
)}
</div>
<div
className="px-3 py-2 border-t"
style={{ borderColor: 'var(--panel-border)' }}
>
<div className="text-sm font-medium truncate">{image.name}</div>
</div>
</div>
)
}
export default memo(ImageCard)

View file

@ -0,0 +1,217 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import { api } from '../../../../api/client'
const PAGE_SIZE = 24
export default function ImagePicker({ open, onClose, onSelect, currentImageId }) {
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
useEffect(() => {
const t = setTimeout(() => {
setDebouncedSearch(search)
setPage(1)
}, 300)
return () => clearTimeout(t)
}, [search])
useEffect(() => {
if (!open) {
setSearch('')
setDebouncedSearch('')
setPage(1)
}
}, [open])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
queryFn: () => {
const params = new URLSearchParams({
page,
limit: PAGE_SIZE,
...(debouncedSearch && { search: debouncedSearch }),
})
return api(`/api/admin/images?${params}`)
},
enabled: open,
placeholderData: (prev) => prev,
})
const images = data?.items || []
const totalPages = data?.total_pages || 1
return (
<AnimatePresence>
{open && (
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
style={{ background: 'var(--dialog-backdrop)' }}
>
<motion.div
key="dialog"
initial={{ opacity: 0, scale: 0.94, y: 8 }}
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-3xl rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col"
style={{
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
borderColor: 'var(--dialog-border)',
}}
>
<div
className="px-6 py-4 border-b flex items-center justify-between shrink-0"
style={{ borderColor: 'var(--panel-border)' }}
>
<h3 className="font-semibold" style={{ color: 'var(--text-strong)' }}>이미지 선택</h3>
<button
onClick={onClose}
className="text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center"
style={{ color: 'var(--text-dim)' }}
>
×
</button>
</div>
{/* 검색 */}
<div className="px-6 pt-4 shrink-0">
<div className="relative">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="이미지 이름으로 검색..."
className="w-full rounded-lg border pl-10 pr-4 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)',
}}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--input-icon)' }}>🔍</span>
</div>
</div>
{/* 이미지 그리드 — 6×4 (24개) 높이로 고정, 내부는 OverlayScrollbars */}
<OverlayScrollbarsComponent
className="shrink-0"
style={{ height: '632px', overscrollBehavior: 'contain' }}
options={{
scrollbars: { theme: 'os-theme-maple os-theme-dark', autoHide: 'leave', autoHideDelay: 800 },
overflow: { x: 'hidden', y: 'scroll' },
}}
defer
>
<div className="px-6 pt-4 pb-6">
{isLoading ? (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-lg animate-pulse"
style={{ background: 'var(--skeleton-bg)' }}
/>
))}
</div>
) : images.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-dim)' }}>
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
</div>
) : (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{images.map((image) => {
const isSelected = currentImageId === image.id
return (
<button
key={image.id}
type="button"
onClick={() => { onSelect(image); onClose() }}
className="group rounded-lg border overflow-hidden"
style={{
borderColor: isSelected ? 'var(--selected-border)' : 'var(--panel-border)',
boxShadow: isSelected ? '0 0 0 2px var(--ring-info)' : undefined,
}}
>
<div
className="aspect-square flex items-center justify-center p-4"
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
>
<img src={image.url} alt={image.name} className="w-full h-full object-contain" style={{ imageRendering: 'pixelated' }} />
</div>
<div
className="px-2 py-1.5 border-t"
style={{
borderColor: 'var(--panel-border)',
background: 'var(--surface-3)',
}}
>
<div className="text-xs truncate">{image.name}</div>
</div>
</button>
)
})}
</div>
)}
</div>
</OverlayScrollbarsComponent>
{/* 페이지네이션 + 액션 (없으면 전체 섹션 숨김) */}
{(totalPages > 1 || currentImageId) && (
<div className="px-6 pb-6 pt-1 flex items-center justify-between shrink-0 gap-3">
{totalPages > 1 ? (
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 flex items-center justify-center text-sm"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
</button>
<span className="text-xs px-2" style={{ color: 'var(--text-muted)' }}>{page} / {totalPages}</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 flex items-center justify-center text-sm"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
</button>
</div>
) : <div />}
{currentImageId && (
<button
type="button"
onClick={() => { onSelect(null); onClose() }}
className="text-sm hover:text-[var(--danger-text-strong)]"
style={{ color: 'var(--danger-text)' }}
>
이미지 제거
</button>
)}
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View file

@ -0,0 +1,71 @@
export default function Pagination({ page, totalPages, onChange }) {
if (totalPages <= 1) return null
const pages = []
const maxButtons = 7
let start = Math.max(1, page - Math.floor(maxButtons / 2))
let end = Math.min(totalPages, start + maxButtons - 1)
if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1)
for (let i = start; i <= end; i++) pages.push(i)
const baseBtn = "min-w-9 h-9 px-3 rounded-lg text-sm flex items-center justify-center border hover:bg-[var(--btn-bg-hover)]"
const btnStyle = {
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}
return (
<div className="flex items-center justify-center gap-1 pt-2">
<button
onClick={() => onChange(page - 1)}
disabled={page === 1}
className={`${baseBtn} disabled:opacity-30`}
style={btnStyle}
>
</button>
{start > 1 && (
<>
<button onClick={() => onChange(1)} className={baseBtn} style={btnStyle}>1</button>
{start > 2 && <span className="px-1" style={{ color: 'var(--text-dim)' }}></span>}
</>
)}
{pages.map((p) => {
const active = p === page
return (
<button
key={p}
onClick={() => onChange(p)}
className={`${baseBtn} ${active ? 'font-medium' : ''}`}
style={active ? {
background: 'var(--selected-bg)',
borderColor: 'var(--selected-border)',
color: 'var(--accent-bright)',
} : btnStyle}
>
{p}
</button>
)
})}
{end < totalPages && (
<>
{end < totalPages - 1 && <span className="px-1" style={{ color: 'var(--text-dim)' }}></span>}
<button onClick={() => onChange(totalPages)} className={baseBtn} style={btnStyle}>{totalPages}</button>
</>
)}
<button
onClick={() => onChange(page + 1)}
disabled={page === totalPages}
className={`${baseBtn} disabled:opacity-30`}
style={btnStyle}
>
</button>
</div>
)
}

View file

@ -0,0 +1,179 @@
import { useState, useEffect } from 'react'
import Modal from '../../../../components/common/Modal'
export default function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
const [items, setItems] = useState([])
const [dragOver, setDragOver] = useState(false)
useEffect(() => {
if (!open) setItems([])
}, [open])
const addFiles = (fileList) => {
const newItems = []
Array.from(fileList).forEach((file) => {
if (!file.type.startsWith('image/')) return
const id = `${Date.now()}-${Math.random()}`
const reader = new FileReader()
reader.onload = (e) => {
setItems((prev) => prev.map((it) => it.id === id ? { ...it, preview: e.target.result } : it))
}
reader.readAsDataURL(file)
newItems.push({
id,
file,
name: file.name.replace(/\.[^.]+$/, ''),
preview: null,
})
})
setItems((prev) => [...prev, ...newItems])
}
const updateName = (id, name) => {
setItems((prev) => prev.map((it) => it.id === id ? { ...it, name } : it))
}
const removeItem = (id) => {
setItems((prev) => prev.filter((it) => it.id !== id))
}
const trimmedNames = items.map((it) => it.name.trim())
const hasEmpty = trimmedNames.some((n) => !n)
const hasDupExisting = trimmedNames.some((n) => existingNames.has(n))
const hasDupInList = trimmedNames.some((n, i) => trimmedNames.indexOf(n) !== i)
const canSubmit = items.length > 0 && !hasEmpty && !hasDupExisting && !hasDupInList
const handleSubmit = async (e) => {
e.preventDefault()
if (!canSubmit) return
await onUpload(items)
}
return (
<Modal open={open} onClose={onClose} title={`이미지 업로드${items.length > 0 ? ` (${items.length})` : ''}`} maxWidth="max-w-2xl">
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="p-6 space-y-4 overflow-y-auto flex-1">
<label
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
addFiles(e.dataTransfer.files)
}}
className="relative rounded-xl border-2 border-dashed cursor-pointer min-h-[120px] flex flex-col items-center justify-center"
style={dragOver ? {
borderColor: 'var(--selected-border)',
background: 'var(--selected-bg)',
} : {
borderColor: 'var(--dashed-border)',
background: 'var(--skeleton-bg)',
}}
>
<div className="text-2xl mb-1 opacity-50">📥</div>
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>클릭하거나 이미지를 끌어다 놓으세요</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-dim)' }}>여러 선택 가능</p>
<input
type="file"
accept="image/*"
multiple
onChange={(e) => { addFiles(e.target.files); e.target.value = '' }}
className="hidden"
/>
</label>
{items.length > 0 && (
<div className="space-y-2">
{items.map((item, idx) => {
const trimmed = item.name.trim()
const dupExisting = trimmed && existingNames.has(trimmed)
const dupInList = trimmed && items.some((it, j) => j !== idx && it.name.trim() === trimmed)
const empty = !trimmed
const errorMsg = empty ? '이름을 입력해주세요'
: dupExisting ? '이미 존재하는 이름입니다'
: dupInList ? '같은 이름이 중복됩니다'
: null
return (
<div
key={item.id}
className="flex items-start gap-3 rounded-lg border p-2"
style={{
background: 'var(--surface-3)',
borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--panel-border)',
}}
>
<div
className="w-12 h-12 rounded flex items-center justify-center overflow-hidden shrink-0"
style={{ background: 'var(--surface-nested)' }}
>
{item.preview ? (
<img src={item.preview} alt="" className="w-full h-full object-contain" />
) : (
<div className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
)}
</div>
<div className="flex-1 min-w-0 space-y-0.5">
<input
type="text"
value={item.name}
onChange={(e) => updateName(item.id, e.target.value)}
className="w-full rounded border px-2 py-1.5 text-sm outline-none"
style={{
background: 'var(--input-bg)',
borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--input-border)',
color: 'var(--text-strong)',
}}
/>
{errorMsg && (
<div className="text-[11px] px-0.5" style={{ color: 'var(--danger-text)' }}>{errorMsg}</div>
)}
</div>
<button
type="button"
onClick={() => removeItem(item.id)}
className="w-7 h-7 rounded shrink-0 hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)]"
style={{ color: 'var(--text-dim)' }}
>
×
</button>
</div>
)
})}
</div>
)}
</div>
<div
className="flex gap-2 px-6 py-4 border-t shrink-0"
style={{ borderColor: 'var(--panel-border)' }}
>
<button
type="button"
onClick={onClose}
className="flex-1 rounded-lg border px-4 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
취소
</button>
<button
type="submit"
disabled={!canSubmit || uploading}
className="flex-1 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
{uploading ? '업로드 중...' : `${items.length > 0 ? `${items.length}` : ''}업로드`}
</button>
</div>
</form>
</Modal>
)
}

View file

@ -1,10 +1,10 @@
import { useEffect, useLayoutEffect } from 'react'
import { useQuery, useQueries } from '@tanstack/react-query'
import { api } from '../../api/client'
import { useLayout } from '../../components/Layout'
import { api } from '../../../api/client'
import { useLayout } from '../../../components/pc/Layout'
import CharacterPanel from './user/CharacterPanel'
import BossSelector from './user/BossSelector'
import { useBossStore } from './store'
import { useBossStore } from '../store'
const MAX_PER_CHARACTER = 12

View file

@ -1,37 +1,16 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../../api/client'
import ConfirmDialog from '../../../components/ConfirmDialog'
import Checkbox from '../../../components/Checkbox'
import Select from '../../../components/Select'
import { useAuthStore } from '../../../stores/auth'
import { api } from '../../../../api/client'
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
import Checkbox from '../../../../components/common/Checkbox'
import Select from '../../../../components/common/Select'
import FormField, { formInputClass, formInputStyle } from '../../../../components/common/FormField'
import { useAuthStore } from '../../../../stores/auth'
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants'
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}` }))
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" style={{ color: 'var(--text-emphasis)' }}>
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
</label>
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
</div>
{children}
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
</div>
)
}
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
const inputStyle = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}
function emptyDifficultyState() {
const obj = {}
DIFFICULTIES.forEach((d) => {
@ -197,28 +176,28 @@ export default function BossForm() {
>
{/* 이름 + 최대 인원 */}
<div className="grid grid-cols-[1fr_auto] gap-3">
<Field label="보스 이름" required error={errors.name}>
<FormField label="보스 이름" required error={errors.name}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="예: 검은 마법사"
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
/>
</Field>
<Field label="최대 인원">
</FormField>
<FormField label="최대 인원">
<Select
value={maxPartySize}
onChange={setMaxPartySize}
options={PARTY_OPTIONS}
className="w-24"
/>
</Field>
</FormField>
</div>
{/* 이미지 */}
<Field label="보스 이미지" required={!isEdit} error={errors.image}>
<FormField label="보스 이미지" required={!isEdit} error={errors.image}>
<label
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
style={{
@ -256,10 +235,10 @@ export default function BossForm() {
className="hidden"
/>
</label>
</Field>
</FormField>
{/* 난이도 */}
<Field label="난이도별 결정 정보" required error={errors.difficulties} hint="활성화한 난이도만 저장됩니다">
<FormField label="난이도별 결정 정보" required error={errors.difficulties} hint="활성화한 난이도만 저장됩니다">
<div className="space-y-2">
{DIFFICULTIES.map((d) => {
const v = difficulties[d.key]
@ -304,7 +283,7 @@ export default function BossForm() {
}}
disabled={!v.enabled}
placeholder="결정 가격"
className="w-full rounded-lg border pl-4 pr-28 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full rounded-lg border pl-4 pr-28 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] disabled:opacity-50"
style={{
background: 'var(--input-bg)',
borderColor: priceErr ? 'var(--icon-danger-border)' : 'var(--input-border)',
@ -326,7 +305,7 @@ export default function BossForm() {
)
})}
</div>
</Field>
</FormField>
<div className="flex items-center gap-2 pt-2">
{isEdit && (

View file

@ -10,8 +10,8 @@ import {
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { api } from '../../../api/client'
import Tooltip from '../../../components/Tooltip'
import { api } from '../../../../api/client'
import Tooltip from '../../../../components/common/Tooltip'
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants'
function BossCardContent({ boss, dragging = false }) {

View file

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { DIFFICULTIES, getDifficultyBadgeStyle, formatMeso, getDifficultyImageUrl } from '../constants'
describe('DIFFICULTIES', () => {
it('5개 난이도 (easy, normal, hard, chaos, extreme)', () => {
expect(DIFFICULTIES.map((d) => d.key)).toEqual(['easy', 'normal', 'hard', 'chaos', 'extreme'])
})
it('모든 항목에 key/label/initial/colors 존재', () => {
DIFFICULTIES.forEach((d) => {
expect(d.key).toBeTruthy()
expect(d.label).toBeTruthy()
expect(d.initial).toBeTruthy()
expect(d.colors).toHaveProperty('bg')
expect(d.colors).toHaveProperty('border')
expect(d.colors).toHaveProperty('text')
})
})
})
describe('getDifficultyBadgeStyle', () => {
it('난이도 객체를 CSS 스타일로 변환', () => {
const s = getDifficultyBadgeStyle('easy')
expect(s.backgroundColor).toBe('#999999')
expect(s.borderColor).toBe('#999999')
expect(s.color).toBe('#ffffff')
})
it('없는 key는 빈 객체', () => {
expect(getDifficultyBadgeStyle('invalid')).toEqual({})
})
})
describe('formatMeso (re-export from utils)', () => {
it('utils/formatting의 formatMeso와 동일 동작', () => {
expect(formatMeso(0)).toBe('0')
expect(formatMeso(100_010_000)).toBe('1억 1만')
})
})
describe('getDifficultyImageUrl', () => {
it('S3 경로 규칙대로 URL 반환', () => {
expect(getDifficultyImageUrl('easy'))
.toBe('https://s3.caadiq.co.kr/maplestory/crystal/difficulty/easy.webp')
expect(getDifficultyImageUrl('chaos'))
.toBe('https://s3.caadiq.co.kr/maplestory/crystal/difficulty/chaos.webp')
})
})

View file

@ -32,15 +32,8 @@ export function getDifficultyBadgeStyle(key) {
}
}
export function formatMeso(n) {
if (!n || n < 10000) return (n || 0).toLocaleString()
if (n >= 100_000_000) {
const uk = Math.floor(n / 100_000_000)
const man = Math.floor((n % 100_000_000) / 10_000)
return man > 0 ? `${uk}${man.toLocaleString()}` : `${uk}`
}
return `${Math.floor(n / 10_000).toLocaleString()}`
}
// formatMeso는 utils/formatting 에서 재-export (모든 기능 공통)
export { formatMeso } from '../../../../utils/formatting'
// difficulty 이미지 URL (S3)
export const DIFFICULTY_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/crystal/difficulty'

View file

@ -1,4 +1,5 @@
import Select from '../../../components/Select'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import Select from '../../../../components/common/Select'
import { DIFFICULTIES, formatMeso } from '../admin/constants'
const LABEL_EN = { easy: 'EASY', normal: 'NORMAL', hard: 'HARD', chaos: 'CHAOS', extreme: 'EXTREME' }
@ -45,7 +46,7 @@ export default function BossSelector({ characterName, bosses, selections, onChan
>
{/* 헤더 (고정) */}
<div
className="flex items-center gap-3 px-3 py-3 border-b text-base font-medium shrink-0"
className="flex items-center gap-3 px-5 py-3 border-b text-base font-medium shrink-0"
style={{
background: 'var(--surface-2)',
borderColor: 'var(--panel-border)',
@ -58,8 +59,15 @@ export default function BossSelector({ characterName, bosses, selections, onChan
<div className="w-32 shrink-0 text-right">가격</div>
</div>
{/* 목록 (스크롤) */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="divide-y" style={{ '--tw-divide-opacity': 1 }}>
<OverlayScrollbarsComponent
className="flex-1 min-h-0"
options={{
scrollbars: { theme: 'os-theme-maple os-theme-dark', autoHide: 'leave', autoHideDelay: 800 },
overflow: { x: 'hidden', y: 'scroll' },
}}
defer
>
<div className="divide-y px-2" style={{ '--tw-divide-opacity': 1 }}>
{bosses.map((boss) => {
const availableDiffs = DIFFICULTIES.filter((d) =>
boss.difficulties.some((bd) => bd.difficulty === d.key)
@ -161,7 +169,7 @@ export default function BossSelector({ characterName, bosses, selections, onChan
)
})}
</div>
</div>
</OverlayScrollbarsComponent>
</div>
)
}

View file

@ -2,11 +2,11 @@ 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'
import CharacterSuggestDropdown from '../../../components/CharacterSuggestDropdown'
import { useFitText } from '../../../hooks/useFitText'
import { api } from '../../../../api/client'
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
import Tooltip from '../../../../components/common/Tooltip'
import CharacterSuggestDropdown from '../../../../components/common/CharacterSuggestDropdown'
import { useFitText } from '../../../../hooks/useFitText'
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
const MAX_PER_CHARACTER = 12

View file

@ -1,458 +0,0 @@
import { useState, useEffect, useLayoutEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { api } from '../../api/client'
import {
GENESIS_CHAPTERS,
GENESIS_TOTAL,
WEEKLY_BOSSES,
MONTHLY_BOSSES,
calcPoints,
formatDate,
todayKST,
} from './data'
import { useLiberationStore } from './store'
import QuestSelector from './components/QuestSelector'
import PointsInput from './components/PointsInput'
import ProgressBar from './components/ProgressBar'
import WeeklyDefault from './components/WeeklyDefault'
import DatePicker from '../../components/DatePicker'
import ConfirmDialog from '../../components/ConfirmDialog'
import { useLayout } from '../../components/Layout'
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 bossEarn(boss, sel) {
if (!sel) return 0
const d = boss.difficulties.find((x) => x.key === sel.difficulty)
if (!d) return 0
return calcPoints(d.points, sel.party)
}
function calcWeekPoints(weekData) {
let points = 0
WEEKLY_BOSSES.forEach((b) => {
points += bossEarn(b, weekData.bosses[b.key])
})
return points
}
function calcDoneEarn(weekData) {
let points = 0
WEEKLY_BOSSES.forEach((b) => {
const sel = weekData.bosses[b.key]
if (sel?.done) points += bossEarn(b, sel)
})
return points
}
function calcMonthlyEarn(weekData) {
return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage)
}
export default function Liberation() {
const { setFullscreen } = useLayout()
useLayoutEffect(() => {
setFullscreen(true)
return () => setFullscreen(false)
}, [setFullscreen])
const [liberationType, setLiberationType] = useState('genesis') // 'genesis' | 'destiny'
const genesisImg = useQuery({
queryKey: ['image', '제네시스 스태프'],
queryFn: () => api('/api/images/' + encodeURIComponent('제네시스 스태프')).catch(() => null),
staleTime: Infinity,
})
const destinyImg = useQuery({
queryKey: ['image', '데스티니 스태프'],
queryFn: () => api('/api/images/' + encodeURIComponent('데스티니 스태프')).catch(() => null),
staleTime: Infinity,
})
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
.slice(0, state.startChapter)
.reduce((s, c) => s + c.required, 0)
let cascadeIdx = state.startChapter
let cascadeRemain = state.currentPoints
let cascadeConsumed = 0
while (cascadeIdx < GENESIS_CHAPTERS.length && cascadeRemain >= GENESIS_CHAPTERS[cascadeIdx].required) {
cascadeConsumed += GENESIS_CHAPTERS[cascadeIdx].required
cascadeRemain -= GENESIS_CHAPTERS[cascadeIdx].required
cascadeIdx++
}
const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
const weeklyEarn = calcWeekPoints(state.weekly)
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
// /
const doneEarn = calcDoneEarn(state.weekly)
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 (remaining <= 0) return dayjs(state.startDate).tz('Asia/Seoul').startOf('day').toDate()
const startKST = dayjs(state.startDate).tz('Asia/Seoul').startOf('day')
const events = []
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')
}
// : (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 })
})
//
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')
}
}
}
events.sort((a, b) => a.date.diff(b.date))
let cumulative = 0
for (const e of events) {
cumulative += e.amount
if (cumulative >= remaining) return e.date.toDate()
}
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 [resetOpen, setResetOpen] = useState(false)
const doReset = () => {
resetSlot()
setResetOpen(false)
}
return (
<div className="space-y-6 pb-10">
{/* 해방 종류 탭 */}
<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) => {
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-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}
completionDate={isDone ? formatDate(completionDate) : null}
/>
{/* 현재 진행 상태 입력 */}
<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 grid-cols-3">
<div className="space-y-1.5">
<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() }))}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
<QuestSelector
value={state.startChapter}
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
/>
</div>
<div className="space-y-1.5">
<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>
<WeeklyDefault
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
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-3xl mx-auto flex justify-end">
<button
type="button"
onClick={() => setResetOpen(true)}
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" />
</svg>
전체 초기화
</button>
</div>
</>)}
<ConfirmDialog
open={resetOpen}
onClose={() => setResetOpen(false)}
onConfirm={doReset}
title="전체 초기화"
description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
confirmText="초기화"
destructive
/>
</div>
)
}

View file

@ -0,0 +1,227 @@
import { describe, it, expect } from 'vitest'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import {
bossEarn,
calcWeekPoints,
calcDoneEarn,
calcMonthlyEarn,
getSchedulerWeekRange,
computeCompletionDate,
} from '../utils'
import { calcPoints, WEEKLY_BOSSES } from '../data'
import { makeEmptyWeekly } from '../store'
dayjs.extend(utc)
dayjs.extend(timezone)
describe('calcPoints', () => {
it('파티원 수로 나누고 버림', () => {
expect(calcPoints(100, 1)).toBe(100)
expect(calcPoints(100, 3)).toBe(33)
expect(calcPoints(50, 2)).toBe(25)
expect(calcPoints(7, 2)).toBe(3) // Math.floor(3.5) = 3
})
})
describe('bossEarn', () => {
const suu = WEEKLY_BOSSES.find((b) => b.key === 'lotus') // 스우
it('선택 없음이면 0', () => {
expect(bossEarn(suu, null)).toBe(0)
expect(bossEarn(suu, undefined)).toBe(0)
})
it('존재하지 않는 난이도면 0', () => {
expect(bossEarn(suu, { difficulty: 'invalid', party: 1 })).toBe(0)
})
it('난이도별 점수를 파티 수로 분배', () => {
// 스우 하드 = 50점
expect(bossEarn(suu, { difficulty: 'hard', party: 1 })).toBe(50)
expect(bossEarn(suu, { difficulty: 'hard', party: 2 })).toBe(25)
expect(bossEarn(suu, { difficulty: 'hard', party: 3 })).toBe(16) // floor(50/3)
})
})
describe('calcWeekPoints / calcDoneEarn', () => {
it('빈 주간 설정은 0', () => {
const empty = makeEmptyWeekly()
expect(calcWeekPoints(empty)).toBe(0)
expect(calcDoneEarn(empty)).toBe(0)
})
it('선택된 보스의 점수 합산', () => {
const cfg = makeEmptyWeekly()
cfg.bosses.lotus = { difficulty: 'hard', party: 1, done: false } // 50
cfg.bosses.damien = { difficulty: 'normal', party: 1, done: false } // 10
expect(calcWeekPoints(cfg)).toBe(60)
expect(calcDoneEarn(cfg)).toBe(0) // 완료 없음
})
it('done=true인 것만 calcDoneEarn 합산', () => {
const cfg = makeEmptyWeekly()
cfg.bosses.lotus = { difficulty: 'hard', party: 1, done: true }
cfg.bosses.damien = { difficulty: 'normal', party: 1, done: false }
expect(calcWeekPoints(cfg)).toBe(60)
expect(calcDoneEarn(cfg)).toBe(50)
})
})
describe('calcMonthlyEarn', () => {
it('검은 마법사 난이도별 점수', () => {
const cfg = makeEmptyWeekly()
expect(calcMonthlyEarn(cfg)).toBe(0) // 기본은 none
cfg.blackMage = { difficulty: 'hard', party: 1, done: false }
expect(calcMonthlyEarn(cfg)).toBe(600)
cfg.blackMage = { difficulty: 'hard', party: 2, done: false }
expect(calcMonthlyEarn(cfg)).toBe(300)
})
})
describe('getSchedulerWeekRange', () => {
it('1주차는 시작일부터 다음 목요일 전날까지', () => {
// 2026-04-19 일요일 시작 → 다음 목요일 2026-04-23
const r = getSchedulerWeekRange('2026-04-19T00:00:00+09:00', 1)
expect(r.start.format('YYYY-MM-DD')).toBe('2026-04-19')
expect(r.end.format('YYYY-MM-DD')).toBe('2026-04-22') // 목요일 전날 (수요일)
})
it('2주차는 다음 목요일부터 7일', () => {
const r = getSchedulerWeekRange('2026-04-19T00:00:00+09:00', 2)
expect(r.start.format('YYYY-MM-DD')).toBe('2026-04-23')
expect(r.end.format('YYYY-MM-DD')).toBe('2026-04-29')
})
it('목요일에 시작하면 그 목요일이 1주차', () => {
// 2026-04-23 목요일 시작
const r1 = getSchedulerWeekRange('2026-04-23T00:00:00+09:00', 1)
expect(r1.start.format('YYYY-MM-DD')).toBe('2026-04-23')
// 1주차 end는 다음 목요일(4/30) 전날 = 4/29
expect(r1.end.format('YYYY-MM-DD')).toBe('2026-04-29')
})
})
describe('computeCompletionDate (단순 계산)', () => {
const baseParams = {
calcMode: 'simple',
alreadyDone: false,
monthlyDoneThisMonth: false,
}
it('alreadyDone이면 오늘 반환', () => {
const r = computeCompletionDate({
...baseParams,
alreadyDone: true,
state: { startDate: '2026-04-19T00:00:00+09:00' },
remaining: 0,
weeklyEarn: 0,
doneEarn: 0,
monthlyEarn: 0,
})
expect(r).toBeInstanceOf(Date)
})
it('remaining=0이면 시작일 반환', () => {
const r = computeCompletionDate({
...baseParams,
state: { startDate: '2026-04-19T00:00:00+09:00' },
remaining: 0,
weeklyEarn: 100,
doneEarn: 0,
monthlyEarn: 0,
})
expect(r).toBeInstanceOf(Date)
expect(dayjs(r).tz('Asia/Seoul').format('YYYY-MM-DD')).toBe('2026-04-19')
})
it('weeklyEarn=0, monthlyEarn=0이면 null', () => {
const r = computeCompletionDate({
...baseParams,
state: { startDate: '2026-04-19T00:00:00+09:00' },
remaining: 1000,
weeklyEarn: 0,
doneEarn: 0,
monthlyEarn: 0,
})
expect(r).toBeNull()
})
it('주 100점 · 6500점 필요 → 약 65주 후 완료', () => {
const r = computeCompletionDate({
...baseParams,
state: { startDate: '2026-04-19T00:00:00+09:00' }, // 일요일
remaining: 6500,
weeklyEarn: 100,
doneEarn: 0,
monthlyEarn: 0,
})
expect(r).toBeInstanceOf(Date)
// 시작 2026-04-19 + 65주 = 2027-07-22 전후 (약 1년 3개월)
const days = dayjs(r).diff(dayjs('2026-04-19T00:00:00+09:00'), 'day')
expect(days).toBeGreaterThan(400)
expect(days).toBeLessThan(500)
})
it('월간 검은 마법사도 반영', () => {
// 주간 0, 월간 600, remaining 1200 → 2회 검마 필요 → 2개월 후
const r = computeCompletionDate({
...baseParams,
state: { startDate: '2026-04-19T00:00:00+09:00' },
remaining: 1200,
weeklyEarn: 0,
doneEarn: 0,
monthlyEarn: 600,
monthlyDoneThisMonth: false,
})
expect(r).toBeInstanceOf(Date)
// 시작 당일 1회 + 다음달 1회 = 2개월 후
expect(dayjs(r).tz('Asia/Seoul').format('YYYY-MM-DD')).toBe('2026-05-01')
})
})
describe('computeCompletionDate (주차별 계산)', () => {
it('schedulerWeeks가 비어있으면 null', () => {
const r = computeCompletionDate({
calcMode: 'weekly',
state: { startDate: '2026-04-19T00:00:00+09:00', schedulerWeeks: [] },
alreadyDone: false,
remaining: 1000,
weeklyEarn: 0,
doneEarn: 0,
monthlyEarn: 0,
monthlyDoneThisMonth: false,
})
expect(r).toBeNull()
})
it('1주차 + 2주차 설정이 서로 다르게 적립', () => {
const week1 = makeEmptyWeekly()
week1.bosses.lotus = { difficulty: 'hard', party: 1, done: false } // 50
const week2 = makeEmptyWeekly()
week2.bosses.lotus = { difficulty: 'hard', party: 1, done: false }
week2.bosses.damien = { difficulty: 'hard', party: 1, done: false } // +50 = 100
const r = computeCompletionDate({
calcMode: 'weekly',
state: {
startDate: '2026-04-19T00:00:00+09:00',
schedulerWeeks: [
{ id: 1, config: week1 },
{ id: 2, config: week2 },
],
},
alreadyDone: false,
remaining: 6500,
weeklyEarn: 0,
doneEarn: 0,
monthlyEarn: 0,
monthlyDoneThisMonth: false,
})
// 1주차 50 + 2주차+ 매주 100 → 6500 도달까지 대략 65주
expect(r).toBeInstanceOf(Date)
})
})

View file

@ -0,0 +1,299 @@
import { useState, useLayoutEffect, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { api } from '../../../api/client'
import {
GENESIS_CHAPTERS,
GENESIS_TOTAL,
MONTHLY_BOSSES,
formatDate,
} from '../data'
import { useLiberationStore } from '../store'
import {
bossEarn,
calcWeekPoints,
calcDoneEarn,
calcMonthlyEarn,
getSchedulerWeekRange,
computeCompletionDate,
} from '../utils'
import QuestSelector from './components/QuestSelector'
import PointsInput from './components/PointsInput'
import ProgressBar from './components/ProgressBar'
import WeeklyDefault from './components/WeeklyDefault'
import DatePicker from '../../../components/common/DatePicker'
import ConfirmDialog from '../../../components/common/ConfirmDialog'
import { useLayout } from '../../../components/pc/Layout'
export default function Liberation() {
const { setFullscreen } = useLayout()
useLayoutEffect(() => {
setFullscreen(true)
return () => setFullscreen(false)
}, [setFullscreen])
const [liberationType, setLiberationType] = useState('genesis') // 'genesis' | 'destiny'
const genesisImg = useQuery({
queryKey: ['image', '제네시스 스태프'],
queryFn: () => api('/api/images/' + encodeURIComponent('제네시스 스태프')).catch(() => null),
staleTime: Infinity,
})
const destinyImg = useQuery({
queryKey: ['image', '데스티니 스태프'],
queryFn: () => api('/api/images/' + encodeURIComponent('데스티니 스태프')).catch(() => null),
staleTime: Infinity,
})
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
.slice(0, state.startChapter)
.reduce((s, c) => s + c.required, 0)
let cascadeIdx = state.startChapter
let cascadeRemain = state.currentPoints
let cascadeConsumed = 0
while (cascadeIdx < GENESIS_CHAPTERS.length && cascadeRemain >= GENESIS_CHAPTERS[cascadeIdx].required) {
cascadeConsumed += GENESIS_CHAPTERS[cascadeIdx].required
cascadeRemain -= GENESIS_CHAPTERS[cascadeIdx].required
cascadeIdx++
}
const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
const weeklyEarn = calcWeekPoints(state.weekly)
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
const doneEarn = calcDoneEarn(state.weekly)
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 = getSchedulerWeekRange(state.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] = bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
return
}
}
})
return Object.values(claimed).reduce((s, v) => s + v, 0)
})()
const completionDate = useMemo(
() => computeCompletionDate({
calcMode, state, alreadyDone, remaining,
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
}),
[calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth],
)
const isDone = completionDate !== null
const [resetOpen, setResetOpen] = useState(false)
const doReset = () => {
resetSlot()
setResetOpen(false)
}
return (
<div className="space-y-6 pb-10">
{/* 해방 종류 탭 */}
<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) => {
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-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}
completionDate={isDone ? formatDate(completionDate) : null}
/>
{/* 현재 진행 상태 입력 */}
<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 grid-cols-3">
<div className="space-y-1.5">
<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() }))}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
<QuestSelector
value={state.startChapter}
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
/>
</div>
<div className="space-y-1.5">
<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>
<WeeklyDefault
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
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-3xl mx-auto flex justify-end">
<button
type="button"
onClick={() => setResetOpen(true)}
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" />
</svg>
전체 초기화
</button>
</div>
</>)}
<ConfirmDialog
open={resetOpen}
onClose={() => setResetOpen(false)}
onConfirm={doReset}
title="전체 초기화"
description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
confirmText="초기화"
destructive
/>
</div>
)
}

View file

@ -1,4 +1,4 @@
import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../data'
import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../../data'
const DOW = ['일', '월', '화', '수', '목', '금', '토']
function formatKoreanDate(s) {

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { GENESIS_CHAPTERS, QUEST_BOSS_IMAGE_BASE } from '../data'
import { GENESIS_CHAPTERS, QUEST_BOSS_IMAGE_BASE } from '../../data'
/**
* 진행 중인 퀘스트 드롭다운

View file

@ -1,7 +1,7 @@
import Select from '../../../components/Select'
import Tooltip from '../../../components/Tooltip'
import Select from '../../../../components/common/Select'
import Tooltip from '../../../../components/common/Tooltip'
import WeeklyScheduler from './WeeklyScheduler'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, calcPoints } from '../data'
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 }
@ -61,7 +61,7 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
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"
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border"
style={disabled ? {
borderColor: 'var(--panel-border)',
color: 'var(--text-dim)',

View file

@ -1,38 +1,10 @@
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 { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES } from '../../data'
import { makeEmptyWeekly } from '../../store'
import { bossEarn, calcWeekPoints as calcWeeklySum, getSchedulerWeekRange as getWeekRange } from '../../utils'
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)}`
@ -46,17 +18,6 @@ const DIFF_BADGE = {
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'
@ -141,7 +102,7 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) {
const weeks = weeksProp && weeksProp.length > 0
? weeksProp
: [{ id: 1, config: makeEmptyWeek() }]
: [{ id: 1, config: makeEmptyWeekly() }]
const setWeeks = (updater) => {
const next = typeof updater === 'function' ? updater(weeks) : updater
onChangeWeeks?.(next)
@ -153,7 +114,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
const id = nextId()
setWeeks((prev) => {
const last = prev[prev.length - 1]
const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeek()
const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeekly()
// done
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
if (base.blackMage) base.blackMage.done = false
@ -289,7 +250,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
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"
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 flex items-center justify-center"
style={{ color: 'var(--text-dim)' }}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">

View file

@ -0,0 +1,174 @@
import dayjs from 'dayjs'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, calcPoints, todayKST } from './data'
import { makeEmptyWeekly } from './store'
const KST = 'Asia/Seoul'
export function bossEarn(boss, sel) {
if (!sel) return 0
const d = boss.difficulties.find((x) => x.key === sel.difficulty)
if (!d) return 0
return calcPoints(d.points, sel.party)
}
export function calcWeekPoints(weekData) {
let points = 0
WEEKLY_BOSSES.forEach((b) => {
points += bossEarn(b, weekData.bosses[b.key])
})
return points
}
export function calcDoneEarn(weekData) {
let points = 0
WEEKLY_BOSSES.forEach((b) => {
const sel = weekData.bosses[b.key]
if (sel?.done) points += bossEarn(b, sel)
})
return points
}
export function calcMonthlyEarn(weekData) {
return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage)
}
/**
* 주차 번호(1-based) 해당 주차의 날짜 범위 반환
* 1주차: 시작일 ~ 다음 목요일 전날
* 2주차+: 이전 주차 목요일부터 6일간
*/
export function getSchedulerWeekRange(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 ws = nextThu.add((weekIdx - 2) * 7, 'day')
return { start: ws, end: ws.add(6, 'day') }
}
/**
* 해방일 계산: 시작일부터 포인트 이벤트를 시뮬레이션하여 remaining 도달 시점 반환
*
* @param {object} params
* @param {'simple'|'weekly'} params.calcMode
* @param {object} params.state - 현재 슬롯(startDate, schedulerWeeks, weekly )
* @param {boolean} params.alreadyDone
* @param {number} params.remaining
* @param {number} params.weeklyEarn
* @param {number} params.doneEarn
* @param {number} params.monthlyEarn
* @param {boolean} params.monthlyDoneThisMonth
* @returns {Date|null}
*/
export function computeCompletionDate({
calcMode, state, alreadyDone, remaining,
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
}) {
if (alreadyDone) return todayKST()
if (remaining <= 0) return dayjs(state.startDate).tz(KST).startOf('day').toDate()
const startKST = dayjs(state.startDate).tz(KST).startOf('day')
const events = []
if (calcMode === 'weekly') {
const sw = state.schedulerWeeks || []
if (sw.length === 0) return null
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')
}
// 검은 마법사: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립
const claimed = {}
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
const date = wIdx === 0
? startKST
: startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day')
events.push({ date, amount: info.earn })
})
// 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용
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')
}
}
}
events.sort((a, b) => a.date.diff(b.date))
let cumulative = 0
for (const e of events) {
cumulative += e.amount
if (cumulative >= remaining) return e.date.toDate()
}
return null
}

View file

@ -1,18 +1,15 @@
/**
* 기능 자동 등록 시스템
*
* - features/{kebab-case}/{PascalCase}.jsx : 사용자 페이지
* - features/{kebab-case}/{PascalCase}Admin.jsx : 관리자 페이지
*
* 예시:
* /boss-crystal features/boss-crystal/BossCrystal.jsx
* /admin/boss-crystal features/boss-crystal/BossCrystalAdmin.jsx
* - features/{kebab-case}/pc/{PascalCase}.jsx : PC 사용자 페이지
* - features/{kebab-case}/pc/{PascalCase}Admin.jsx: PC 관리자 페이지
* - features/{kebab-case}/tablet/{PascalCase}.jsx : 태블릿 사용자 페이지
* - features/{kebab-case}/mobile/{PascalCase}.jsx : 모바일 사용자 페이지
*/
import { lazy } from 'react'
// Vite의 import.meta.glob으로 features 폴더 전체를 스캔
const userPages = import.meta.glob('./*/*.jsx')
const pages = import.meta.glob('./*/{pc,tablet,mobile}/*.jsx')
function slugToPascal(slug) {
return slug
@ -21,33 +18,47 @@ function slugToPascal(slug) {
.join('')
}
// 컴포넌트 캐시 - 동일 slug에 대해 항상 같은 컴포넌트 인스턴스 반환
// (매 렌더마다 새 lazy() 생성하면 React가 unmount/remount하면서 화면 갱신이 깨짐)
const userCache = new Map()
const adminCache = new Map()
const userPcCache = new Map()
const adminPcCache = new Map()
const userTabletCache = new Map()
const userMobileCache = new Map()
function loadCached(cache, slug, suffix) {
function loadCached(cache, slug, device, suffix) {
if (cache.has(slug)) return cache.get(slug)
const pascal = slugToPascal(slug)
const path = `./${slug}/${pascal}${suffix}.jsx`
const loader = userPages[path]
const path = `./${slug}/${device}/${pascal}${suffix}.jsx`
const loader = pages[path]
const component = loader ? lazy(loader) : null
cache.set(slug, component)
return component
}
/**
* slug에 해당하는 사용자 페이지 컴포넌트 반환
* slug에 해당하는 PC 사용자 페이지 컴포넌트 반환
*/
export function getUserComponent(slug) {
return loadCached(userCache, slug, '')
return loadCached(userPcCache, slug, 'pc', '')
}
/**
* slug에 해당하는 관리자 페이지 컴포넌트 반환
* slug에 해당하는 관리자 페이지 컴포넌트 반환 (PC 전용)
*/
export function getAdminComponent(slug) {
return loadCached(adminCache, slug, 'Admin')
return loadCached(adminPcCache, slug, 'pc', 'Admin')
}
/**
* slug에 해당하는 태블릿 사용자 페이지 컴포넌트 반환
*/
export function getTabletComponent(slug) {
return loadCached(userTabletCache, slug, 'tablet', '')
}
/**
* slug에 해당하는 모바일 사용자 페이지 컴포넌트 반환
*/
export function getMobileComponent(slug) {
return loadCached(userMobileCache, slug, 'mobile', '')
}
/**

View file

@ -1,717 +0,0 @@
import { useState, useEffect, useLayoutEffect, 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 CharacterSuggestDropdown from '../../components/CharacterSuggestDropdown'
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()
useLayoutEffect(() => {
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 [dropdownOpen, setDropdownOpen] = useState(false)
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('') }}
onFocus={() => setDropdownOpen(true)}
onBlur={() => setTimeout(() => setDropdownOpen(false), 150)}
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)',
}}
/>
<CharacterSuggestDropdown
open={dropdownOpen}
filter={addName}
excludeNames={characters.map((c) => c.character_name)}
onSelect={(n) => {
setAddName(n)
setDropdownOpen(false)
setAddError('')
searchMutation.mutate(n)
}}
/>
</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,106 @@
import { describe, it, expect } from 'vitest'
import { formatKoreanDate, computeCompletion, TYPE_ORDER, eventBonusForType } from '../utils'
describe('TYPE_ORDER', () => {
it('아케인 → 어센틱 → 그랜드 어센틱 순서', () => {
expect(TYPE_ORDER).toEqual(['아케인', '어센틱', '그랜드 어센틱'])
})
})
describe('formatKoreanDate', () => {
it('YYYY년 MM월 DD일 (요일) 형식', () => {
const d = new Date('2026-04-19T00:00:00+09:00')
const s = formatKoreanDate(d)
expect(s).toMatch(/^2026년 04월 19일 \([일월화수목금토]\)$/)
})
it('월/일 2자리 zero-padding', () => {
expect(formatKoreanDate(new Date('2026-01-05T00:00:00+09:00')))
.toMatch(/^2026년 01월 05일 /)
})
})
describe('computeCompletion', () => {
it('need가 0 (extra로 커버)이면 days 0', () => {
const r = computeCompletion({
remainingSymbols: 100,
daily: 1,
weeklyPerWeek: 0,
extra: 200,
dailyDone: false,
})
expect(r.days).toBe(0)
expect(r.date).toBeInstanceOf(Date)
})
it('daily/weekly 모두 0이면 불가능', () => {
const r = computeCompletion({
remainingSymbols: 100,
daily: 0,
weeklyPerWeek: 0,
extra: 0,
dailyDone: false,
})
expect(r.days).toBeNull()
expect(r.date).toBeNull()
})
it('일퀘 하루 1개 · 100개 필요 → 100일 후 완료', () => {
const r = computeCompletion({
remainingSymbols: 100,
daily: 1,
weeklyPerWeek: 0,
extra: 0,
dailyDone: false,
})
// dailyDone=false라 오늘(day 0)도 적립 → 1/day 누적 100일
expect(r.days).toBe(99) // day 0: 1, day 1: 2, ..., day 99: 100 ≥ 100
})
it('dailyDone이면 오늘은 적립 안 됨 → 하루 더 걸림', () => {
const r1 = computeCompletion({
remainingSymbols: 10, daily: 1, weeklyPerWeek: 0, extra: 0, dailyDone: false,
})
const r2 = computeCompletion({
remainingSymbols: 10, daily: 1, weeklyPerWeek: 0, extra: 0, dailyDone: true,
})
expect(r2.days).toBe(r1.days + 1)
})
it('extra가 remaining을 전부 덮으면 즉시 완료', () => {
const r = computeCompletion({
remainingSymbols: 50,
daily: 5,
weeklyPerWeek: 10,
extra: 100,
dailyDone: false,
})
expect(r.days).toBe(0)
})
})
describe('eventBonusForType', () => {
const skill = { skill_name: '메이플 스위츠', skill_level: 1, arcane_daily: 3, authentic_daily: 7 }
it('event_skill이 없으면 0', () => {
expect(eventBonusForType(null, '아케인')).toBe(0)
expect(eventBonusForType(undefined, '어센틱')).toBe(0)
})
it('아케인 타입은 arcane_daily 반환', () => {
expect(eventBonusForType(skill, '아케인')).toBe(3)
})
it('어센틱/그랜드 어센틱 타입은 authentic_daily 반환', () => {
expect(eventBonusForType(skill, '어센틱')).toBe(7)
expect(eventBonusForType(skill, '그랜드 어센틱')).toBe(7)
})
it('알 수 없는 타입은 0', () => {
expect(eventBonusForType(skill, '기타')).toBe(0)
})
it('해당 필드가 누락되어도 0으로 처리', () => {
expect(eventBonusForType({ skill_name: 'X', skill_level: 1 }, '아케인')).toBe(0)
})
})

View file

@ -0,0 +1,354 @@
import { useState, useEffect, useLayoutEffect, useMemo } from 'react'
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
import { api } from '../../../api/client'
import { useLayout } from '../../../components/pc/Layout'
import Tooltip from '../../../components/common/Tooltip'
import CharacterSuggestDropdown from '../../../components/common/CharacterSuggestDropdown'
import { useSymbolStore } from '../store'
import { formatMesoKorean } from '../../../utils/formatting'
import { formatKoreanDate, computeCompletion, TYPE_ORDER, eventBonusForType } from '../utils'
import CharacterCard from './user/CharacterCard'
import SymbolCard from './user/SymbolCard'
export default function Symbol() {
const { setFullscreen } = useLayout()
useLayoutEffect(() => {
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: useMemo(() => 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,
})), [characters]),
})
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: useMemo(() => characters.map((c) => ({
queryKey: ['character', 'symbols', c.id],
queryFn: () => api(`/api/character/symbols?ocid=${c.id}`),
enabled: !!c.id,
refetchOnMount: 'always',
staleTime: 0,
})), [characters]),
})
// symbolQueries store
useEffect(() => {
if (!allSymbols.length || !characters.length) return
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)
const nextEs = q.data.event_skill ?? null
const prevEs = c.event_skill ?? null
if (JSON.stringify(nextEs) !== JSON.stringify(prevEs)) {
updateCharacter(c.id, { event_skill: nextEs })
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allSymbols, symbolQueries.map((q) => q.dataUpdatedAt).join(',')])
const [addName, setAddName] = useState('')
const [addError, setAddError] = useState('')
const [dropdownOpen, setDropdownOpen] = useState(false)
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 selectedChar = characters.find((c) => c.id === selectedCharId)
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
let arrLv = p.level, arrG = p.growth || 0
while (arrLv < s.max_level) {
const lv2 = s.levels?.find((x) => x.level === arrLv)
if (!lv2 || arrG < lv2.required_count) break
arr += lv2.meso_cost
arrG -= lv2.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 bonus = eventBonusForType(selectedChar?.event_skill, s.type)
const dailyValue = p.daily !== undefined ? p.daily : (s.daily_default ?? 0) + bonus
const { date } = computeCompletion({
remainingSymbols: remaining,
daily: dailyValue,
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, selectedChar?.event_skill])
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('') }}
onFocus={() => setDropdownOpen(true)}
onBlur={() => setTimeout(() => setDropdownOpen(false), 150)}
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)',
}}
/>
<CharacterSuggestDropdown
open={dropdownOpen}
filter={addName}
excludeNames={characters.map((c) => c.character_name)}
onSelect={(n) => {
setAddName(n)
setDropdownOpen(false)
setAddError('')
searchMutation.mutate(n)
}}
/>
</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

@ -1,10 +1,12 @@
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'
import { useAuthStore } from '../../../stores/auth'
import { api } from '../../../../api/client'
import Select from '../../../../components/common/Select'
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
import FormField, { formInputClass, formInputStyle } from '../../../../components/common/FormField'
import { useAuthStore } from '../../../../stores/auth'
import { formatMeso } from '../../../../utils/formatting'
const TYPE_OPTIONS = [
{ value: '아케인', label: '아케인' },
@ -12,27 +14,9 @@ const TYPE_OPTIONS = [
{ value: '그랜드 어센틱', label: '그랜드 어센틱' },
]
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
const inputStyle = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}
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)
const korean = formatMeso(Number(String(value).replace(/[^\d]/g, '')) || 0)
return (
<div>
<input
@ -43,35 +27,20 @@ function MesoInput({ value, onChange, ...rest }) {
const digits = e.target.value.replace(/[^\d]/g, '')
onChange(digits)
}}
className={`${inputCls} tabular-nums text-right`}
style={inputStyle}
className={`${formInputClass} tabular-nums text-right`}
style={formInputStyle}
{...rest}
/>
<div
className="text-sm mt-1 text-right tabular-nums min-h-[18px]"
style={{ color: 'var(--warning-text-bright)' }}
>
{korean || '\u00A0'}
{korean === '0' ? '\u00A0' : korean}
</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" style={{ color: 'var(--text-emphasis)' }}>
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
</label>
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
</div>
{children}
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
</div>
)
}
export default function SymbolForm() {
const navigate = useNavigate()
const queryClient = useQueryClient()
@ -216,7 +185,7 @@ export default function SymbolForm() {
<div className="rounded-2xl border p-6 space-y-5" style={panelStyle}>
<div className="text-sm font-semibold" style={{ color: 'var(--accent-bright)' }}>기본 정보</div>
<Field label="심볼 이미지" required={!isEdit}>
<FormField label="심볼 이미지" required={!isEdit}>
<label
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
style={{
@ -248,54 +217,54 @@ export default function SymbolForm() {
</div>
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
</label>
</Field>
</FormField>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="심볼 종류" required>
<FormField label="심볼 종류" required>
<Select value={type} onChange={setType} options={TYPE_OPTIONS} />
</Field>
<Field label="지역 이름" required hint="예: 소멸의 여로">
</FormField>
<FormField label="지역 이름" required hint="예: 소멸의 여로">
<input
type="text"
value={region}
onChange={(e) => setRegion(e.target.value)}
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
placeholder="소멸의 여로"
/>
</Field>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Field label="만렙" required>
<FormField label="만렙" required>
<input
type="number"
value={maxLevel}
onChange={(e) => { setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }}
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
min="2"
/>
</Field>
<Field label="기본 일퀘 획득량">
</FormField>
<FormField label="기본 일퀘 획득량">
<input
type="number"
value={dailyDefault}
onChange={(e) => setDailyDefault(e.target.value)}
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
/>
</Field>
<Field label="기본 주간퀘 획득량">
</FormField>
<FormField label="기본 주간퀘 획득량">
<input
type="number"
value={weeklyDefault}
onChange={(e) => setWeeklyDefault(e.target.value)}
className={inputCls}
style={inputStyle}
className={formInputClass}
style={formInputStyle}
/>
</Field>
</FormField>
</div>
</div>
</div>
@ -329,8 +298,8 @@ export default function SymbolForm() {
type="number"
value={l.required_count}
onChange={(e) => updateLevel(idx, 'required_count', e.target.value)}
className={`${inputCls} max-w-36`}
style={inputStyle}
className={`${formInputClass} max-w-36`}
style={formInputStyle}
placeholder="0"
/>
</td>

View file

@ -10,7 +10,7 @@ import {
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { api } from '../../../api/client'
import { api } from '../../../../api/client'
const TYPE_STYLE = {
'아케인': {

View file

@ -0,0 +1,57 @@
import { memo } from 'react'
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>
)
}
export default memo(CharacterCard)

View file

@ -0,0 +1,258 @@
import { memo, useMemo } from 'react'
import Select from '../../../../components/common/Select'
import Tooltip from '../../../../components/common/Tooltip'
import { useSymbolStore } from '../../store'
import { formatMesoKorean } from '../../../../utils/formatting'
import { formatKoreanDate, computeCompletion, eventBonusForType } from '../../utils'
const INPUT_CLASS = "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"
const INPUT_STYLE = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}
function SymbolCard({ symbol, equipped, charId }) {
const progress = useSymbolStore((s) => s.progress?.[charId]?.[symbol.id])
const updateSymbol = useSymbolStore((s) => s.updateSymbol)
const eventSkill = useSymbolStore((s) => s.characters.find((c) => c.id === charId)?.event_skill)
const dailyDone = progress?.dailyDone ?? false
const weeklyCount = progress?.weeklyCount ?? 3
const baseDefault = symbol.daily_default ?? 0
const eventBonus = eventBonusForType(eventSkill, symbol.type)
const hasDailyOverride = progress?.daily !== undefined
const daily = hasDailyOverride ? progress.daily : baseDefault + eventBonus
const extra = progress?.extra ?? 0
const patch = (p) => charId && updateSymbol(charId, symbol.id, p)
const dailyTooltip = !hasDailyOverride && eventBonus > 0 && eventSkill
? `기본 ${baseDefault} + 보약 ${eventBonus} (${eventSkill.skill_name} Lv.${eventSkill.skill_level})`
: null
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])
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"
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={INPUT_CLASS}
style={INPUT_STYLE}
{...(dailyTooltip ? { title: dailyTooltip } : {})}
/>
</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={INPUT_CLASS}
style={INPUT_STYLE}
/>
</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) => (
<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 memo(SymbolCard)

View file

@ -3,7 +3,8 @@ import { persist } from 'zustand/middleware'
/**
* 심볼 계산기 상태
* characters: [{ id, character_name, character_level, job_name, character_image, ... }]
* characters: [{ id, character_name, character_level, job_name, character_image, event_skill, ... }]
* - event_skill: { skill_name, skill_level, arcane_daily, authentic_daily } | null
* selectedCharId: 현재 선택된 캐릭터 id (ocid)
* progress: {
* [charId]: {

View file

@ -0,0 +1,46 @@
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
dayjs.extend(utc)
dayjs.extend(timezone)
export const KST = 'Asia/Seoul'
const DOW = ['일', '월', '화', '수', '목', '금', '토']
export 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이면 오늘 일퀘는 이미 받은 걸로 간주 (내일부터 다시 지급)
*/
export 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++) {
if (!(day === 0 && dailyDone)) acc += daily
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 }
}
export const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']
// 이벤트 스킬(보약) 보너스 중 해당 심볼 타입에 적용되는 일퀘 증가량 반환
export function eventBonusForType(eventSkill, type) {
if (!eventSkill) return 0
if (type === '아케인') return eventSkill.arcane_daily || 0
if (type === '어센틱' || type === '그랜드 어센틱') return eventSkill.authentic_daily || 0
return 0
}

View file

@ -354,10 +354,6 @@ a {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
/* number input 화살표 숨기기 */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {

View file

@ -1,7 +1,8 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import NoticeWidget from '../components/NoticeWidget'
import { api } from '../../api/client'
import NoticeWidget from '../../components/pc/NoticeWidget'
import SundayMapleBanner from '../../components/pc/SundayMapleBanner'
export default function Home() {
const { data: menus = [], isLoading: loading } = useQuery({
@ -11,6 +12,9 @@ export default function Home() {
return (
<div className="space-y-10 max-w-5xl mx-auto pt-6">
{/* 썬데이 메이플 배너 (금~일만 표시) */}
<SundayMapleBanner />
{/* 구분선 */}
<div className="flex items-center gap-4">
<div

View file

View file

@ -0,0 +1,26 @@
import { Routes, Route } from 'react-router-dom'
/**
* 모바일 라우트 (placeholder)
* 추후 MobileLayout, 기능별 모바일 페이지 등록 예정
*/
export default function MobileRoutes() {
return (
<Routes>
<Route
path="*"
element={
<div
className="min-h-screen flex items-center justify-center p-6 text-center"
style={{ color: 'var(--text-muted)' }}
>
<div>
<div className="text-4xl mb-3 opacity-50">📱</div>
<p className="text-sm">모바일 버전은 준비 중입니다</p>
</div>
</div>
}
/>
</Routes>
)
}

View file

@ -0,0 +1,31 @@
import { Routes, Route } from 'react-router-dom'
import Layout from '../components/pc/Layout'
import Home from '../pages/pc/Home'
import FeaturePage from '../features/FeaturePage'
import AdminLayout from '../features/admin/pc/AdminLayout'
import AdminHome from '../features/admin/pc/AdminHome'
import AdminImages from '../features/admin/pc/AdminImages'
import AdminMenuForm from '../features/admin/pc/AdminMenuForm'
import AdminFeaturePage from '../features/admin/pc/AdminFeaturePage'
export default function PCRoutes() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<Home />} />
{/* 관리자 */}
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminHome />} />
<Route path="images" element={<AdminImages />} />
<Route path="menus/new" element={<AdminMenuForm />} />
<Route path="menus/:id" element={<AdminMenuForm />} />
<Route path=":slug/*" element={<AdminFeaturePage />} />
</Route>
{/* 동적 기능 페이지 */}
<Route path="/:slug/*" element={<FeaturePage />} />
</Route>
</Routes>
)
}

View file

@ -0,0 +1,26 @@
import { Routes, Route } from 'react-router-dom'
/**
* 태블릿 라우트 (placeholder)
* 추후 TabletLayout, 기능별 태블릿 페이지 등록 예정
*/
export default function TabletRoutes() {
return (
<Routes>
<Route
path="*"
element={
<div
className="min-h-screen flex items-center justify-center p-6 text-center"
style={{ color: 'var(--text-muted)' }}
>
<div>
<div className="text-4xl mb-3 opacity-50">📱</div>
<p className="text-sm">태블릿 버전은 준비 중입니다</p>
</div>
</div>
}
/>
</Routes>
)
}

View file

@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { formatMeso, formatMesoKorean } from '../formatting'
describe('formatMeso', () => {
it('0 이하는 "0" 반환', () => {
expect(formatMeso(0)).toBe('0')
expect(formatMeso(-100)).toBe('0')
expect(formatMeso(null)).toBe('0')
expect(formatMeso(undefined)).toBe('0')
})
it('1만 미만은 그대로 locale 표기', () => {
expect(formatMeso(500)).toBe('500')
expect(formatMeso(9999)).toBe('9,999')
})
it('만 단위만', () => {
expect(formatMeso(10000)).toBe('1만')
expect(formatMeso(12345)).toBe('1만')
expect(formatMeso(99_990_000)).toBe('9,999만')
})
it('억 단위만', () => {
expect(formatMeso(100_000_000)).toBe('1억')
expect(formatMeso(500_000_000)).toBe('5억')
})
it('억 + 만 조합', () => {
expect(formatMeso(100_010_000)).toBe('1억 1만')
expect(formatMeso(123_456_789)).toBe('1억 2,345만')
expect(formatMeso(2_576_000_000)).toBe('25억 7,600만')
})
it('formatMesoKorean은 formatMeso와 동일', () => {
expect(formatMesoKorean(100_010_000)).toBe(formatMeso(100_010_000))
expect(formatMesoKorean(0)).toBe('0')
})
it('문자열 입력도 처리', () => {
expect(formatMeso('12345')).toBe('1만')
expect(formatMeso('100000000')).toBe('1억')
})
})

View file

@ -0,0 +1,20 @@
/**
* 메소를 "N억 N,NNN만" 형식의 한국어 문자열로 반환
* formatMeso(123456789) "1억 2,345만"
* formatMeso(10000) "1만"
* formatMeso(500) "500"
* formatMeso(0) "0"
*/
export function formatMeso(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()
}
// 과거 이름 alias (symbol에서 formatMesoKorean으로 쓰던 것)
export const formatMesoKorean = formatMeso