Compare commits

...

23 commits

Author SHA1 Message Date
281332ad14 보스 이미지 loading=lazy, decoding=async 추가
이전 revert 과정에서 함께 사라졌던 이미지 비동기 로딩 속성 복원.
애니메이션과 무관한 최적화이므로 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:49:45 +09:00
f845e74844 Revert "MapleStory 폰트 font-display: optional 로 override해 CLS 제거"
This reverts commit 6e2159cf67.
2026-04-22 00:47:33 +09:00
98b27a5fae 페이지 전환 애니메이션 전체 revert
유저 체감 개선이 확실치 않고 오히려 버벅임 느낌이 남아있어 관련 6개
커밋 (d1764de, 1344a2f, 48f43ec, f5c5c69, 670d8ab, f63c1e0) 을 git revert.
StaggerGroup 컴포넌트 제거, Feature/Admin 페이지의 Suspense 스피너 복원,
보스 리스트의 border 구조 원복.

prefetch(7ebfe4a), backdrop-blur 제거(669b358), font-display optional
(6e2159c) 은 애니메이션 무관한 최적화라 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:46:46 +09:00
f63c1e06c5 애니메이션을 translateY 대신 scale로 변경해 CLS 제거
Chrome DevTools의 레이아웃 변경 원인 분석 결과 애니메이션 × 3이 CLS 0.17의
주요 원인으로 지목됨. translateY 애니메이션을 레이아웃 변경으로 카운트하는
케이스가 있음.

- StaggerGroup: y:30 → scale:0.97 로 변경. scale/opacity는 compositor-only
  속성이라 layout/paint 없이 GPU만 사용
- BossCrystal 루트 애니메이션도 동일하게 scale 기반으로 변경
- 자식 motion.div에 will-change: transform, opacity 명시적 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:42:25 +09:00
669b358460 렌더 병목 2곳 제거: 헤더 backdrop-blur + 전역 배경 transition
프로미스나인과 비교 분석 결과 메이플에만 있는 두 가지 무거운 패턴.

- Layout 헤더의 backdrop-blur-md 제거하고 불투명 배경(var(--bg-from))으로
  교체. sticky 헤더 아래 컨텐츠가 애니메이션될 때마다 frosted glass
  필터를 매 프레임 재계산하던 비용 제거 (fromis_9는 shadow-sm만 사용).
- html/body/#root의 background-color/image 500ms transition 제거.
  테마 전환 부드럽게 하려는 의도였지만 범위가 전역이라 페이지 렌더
  성능 발목을 잡음. 테마 토글은 이제 즉시 전환.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:34:10 +09:00
6e2159cf67 MapleStory 폰트 font-display: optional 로 override해 CLS 제거
개발자도구 Performance 분석 결과 CLS 0.17로 레이아웃 이동이 큼. JSDelivr
Maplestory.css의 font-display: swap이 폰트 도착 시 텍스트 너비를 swap
시켜서 카드 애니메이션 중에 레이아웃 튀어 버벅임 발생.

- JSDelivr CSS 링크 제거하고 @font-face를 index.html에 직접 선언하며
  font-display: optional 로 변경
- woff2만 참조 (fallback 포맷 제거)
- cdn.jsdelivr.net preconnect 추가해 첫 방문 시에도 빠르게 시도

첫 방문 시: fallback(Noto Sans KR)으로 즉시 렌더, Maplestory가 100ms
내 도착하지 못하면 그대로 유지. 이후 캐시된 재방문부터는 정상 적용.
레이아웃 이동 0, 애니메이션 부드럽게 진행.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:29:39 +09:00
670d8abc12 보스 수익 계산기 카드 슬라이드업 애니메이션 복원 + GPU 힌트 강화
- initial y:30, duration 0.4 복원하되 will-change: transform, opacity
  명시해 GPU 레이어 승격 확실히
- delay 0.03초 추가해 첫 프레임 레이아웃 안정화 후 애니메이션 시작

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:26:14 +09:00
f5c5c6927e 보스 수익 계산기 초기 등장 끊김 완화
- BossCrystal 루트 애니메이션을 y 이동 없는 opacity fade (0.3s)로 단순화.
  외부 transform과 내부 stagger 애니메이션이 동시에 돌면서 합성 단계가
  겹치던 부하 제거
- will-change: opacity 힌트 추가해 GPU 레이어 승격 명시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:22:42 +09:00
48f43ecc0b 보스 리스트 스태거 개선: 구분선 중복 제거 + 버벅임 완화
- 내부 row의 border-t/first:border-t-0 제거, 부모 divide-y로 통일
  (motion.div 래핑으로 first-child 판정이 어긋나 구분선 중복 생기던 문제)
- divide color는 divide-[var(--panel-border)]로 토큰 직접 지정
- stagger 파라미터 완화: yOffset 20→10, duration 0.3→0.25, 간격 0.04→0.03
  (동시 애니메이션/이미지 디코드 부담 감소)
- 보스 이미지에 loading=lazy, decoding=async 추가해 초기 프레임 블로킹 방지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:19:13 +09:00
1344a2f7a9 StaggerGroup 파라미터를 프로미스나인 사이트와 동일하게 맞춤 + 보스 리스트 애니메이션
- StaggerGroup 기본값: y 30px / duration 0.4s / 간격 0.1s, default ease
  (프로미스나인 AlbumDetail 패턴과 일치)
- staggerDelay / yOffset / duration prop 받도록 커스터마이즈 가능
- BossCrystal fade-in 파라미터도 동일하게 맞춤
- BossSelector 보스 리스트에 StaggerGroup 적용 (항목 많으므로 간격 0.04s,
  y 20px, duration 0.3s 로 가볍게)
- CharacterPanel 은 Reorder.Group 드래그 로직과 충돌하므로 제외

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:16:56 +09:00
d1764dea94 feature 페이지 Suspense 스피너 제거 + 섹션 순차 페이드인 애니메이션
- FeaturePage / AdminFeaturePage의 Suspense fallback 스피너를 null로 변경
- components/common/StaggerGroup: 자식을 각 motion.div로 감싸 순차
  페이드인 (staggerChildren 0.07s, duration 0.35s, ease 0.22,1,0.36,1)
- Liberation(Genesis/Destiny), Symbol 페이지 root를 StaggerGroup으로 교체
- BossCrystal은 grid 레이아웃 특성상 root 전체를 motion.div로 감싸 fade-in
- hover prefetch와 함께 chunk 로드 시 깜빡임 없이 자연스럽게 등장

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:13:15 +09:00
7ebfe4a449 홈 메뉴 카드 hover 시 기능 페이지 chunk prefetch
React.lazy 로 분할된 feature 번들을 메뉴 카드 hover/focus 시점에 미리
fetch 해서, 클릭→네비게이션 시점에는 이미 로드되어 있도록 함.
Suspense 깜빡임(~0.5s) 제거.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:48:43 +09:00
ea9a6461f2 배포 모드 전환: 프로덕션 Dockerfile + docker-compose 재구성
- frontend/Dockerfile: 멀티스테이지 (node 빌드 → serve로 dist 정적 서빙).
  포트 5173은 그대로 유지해서 Caddy 설정 변경 불필요
- backend/Dockerfile: npm ci --omit=dev + npm start (node --watch 제거)
- docker-compose.yml: volumes/모듈 마운트 제거, build 기반으로 변경,
  restart: unless-stopped, NODE_ENV=production 추가
- .dockerignore 양쪽 추가해서 이미지에 node_modules/.git 제외

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:38:12 +09:00
66cafdb540 데드 코드 제거
- QUEST_BTBOSS_IMAGE_BASE 상수: 정의만 있고 아무데서도 참조 안 함
- DESTINY_CHAPTERS[].image 필드: 6개 항목에 있었지만 읽는 곳이 없음
  (ProgressBar/QuestSelector가 chapter.boss + '.webp' 패턴으로 파일명 생성)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:35:01 +09:00
c072cccf44 이미지 서비스 통합: boss-crystal/image.js 제거 + safeDelete 헬퍼 추가
- services/boss-crystal/image.js의 uploadBossImage/deleteBossImage는
  services/image.js의 convertAndUploadTo와 safeDelete로 대체 가능해서 제거
- services/image.js에 safeDelete(path) 헬퍼 추가 (삭제 실패해도 흐름을
  끊지 않고 warn 로그). 기존에 try/catch 인라인으로 흩어져있던 세 곳 통일
- routes/admin/boss-crystal.js에 BOSS_IMAGE_PREFIX 상수 인라인

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:33:58 +09:00
b3907ec48f 공용 상수 backend/constants.js로 추출
DIFFICULTIES(난이도 enum), PARTY_SIZE(1~6), SYMBOL_MASTER_LEVEL(2~99),
UPLOAD_FILE_SIZE_LIMIT(10MB)를 backend/constants.js로 추출하고 admin.js,
boss-crystal.js, symbol.js, BossDifficulty 모델에서 참조.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:31:43 +09:00
1ee3f19f4f 데스티니 해방 계산 로직 완성 + 완료일 색상 정리
- 포인트 이월(cascade), 남은 포인트, 예상 해방일 계산을 Destiny에 연결
  (computeCompletionDate에 bosses/monthlyBoss/makeEmptyConfig 파라미터 추가)
- loop 후에도 미달이면 정상 상태 주간 획득량으로 선형 외삽해서 날짜 반환.
  Genesis·Destiny 모두 적용 (제네시스도 낮은 설정에서 '미정' 떴던 버그 해결)
- 전체 초기화 버튼 + ConfirmDialog 데스티니에도 추가
- --genesis-date / --destiny-date 토큰 분리. 다크는 amber-300 / sky-400,
  라이트는 가독성을 위해 amber-500 / sky-500로 한 톤 어둡게

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:16:03 +09:00
ee30c87518 데스티니 주차별 계산 UI + 탭 라벨 변경
- WeeklyScheduler를 bosses/monthlyBoss/imageBase/makeEmptyConfig prop
  받도록 일반화 (월간 보스 없으면 관련 UI/락 전부 스킵)
- WeeklyDefault가 WeeklyScheduler에 props 전달, Destiny에서 주차별 모드
  주차 카드 확장/추가/삭제 + 보스 아바타 뱃지 표시 동작
- 탭 '단순 계산/주차별 계산' → '일반/주차별' (ConfirmDialog 문구 포함)
- 주간 보스 완료 토글 버튼에 title 툴팁 추가
- store: destiny 슬롯에 schedulerWeeks 추가, migrate v2로 기존 사용자 backfill

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:04:46 +09:00
99500d91af 데스티니 해방에 단순 계산 모드 주간 보스 설정 추가
WeeklyDefault/BossRow를 bosses/imageBase/hasScheduler prop 받도록 일반화.
데스티니 단순 계산 모드에서 8개 보스의 난이도·파티 인원·완료 상태를 설정할
수 있도록 구현. 주차별 계산은 플레이스홀더.

store에 destiny weekly 슬롯 추가하고 v1 migrate로 기존 사용자의 localStorage
backfill 처리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:59:43 +09:00
d506c022ca 데스티니 해방에 현재 진행 상태 섹션 추가
QuestSelector를 chapters/imageBase prop 받도록 일반화한 뒤, Destiny에
시작 날짜/진행 중인 결전/현재 결의 입력 섹션 구현. 결의 입력 max는
20,000 (6챕터 요구량 15,000 여유 포함).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:55:16 +09:00
0448b0bfc8 해방 탭 상태 분리 (제네시스/데스티니 각각)
- Liberation.jsx를 탭 shell로 단순화하고 Genesis/Destiny 컴포넌트 분리
- liberationType을 store에 persist해 새로고침/재접속 후에도 마지막 탭 유지
- calcMode를 genesisCalcMode/destinyCalcMode로 분리해 무기별 독립 저장
- ProgressBar를 chapters/imageBase/completionColor prop 받도록 일반화
- Destiny 컴포넌트에 계산 모드 탭 + 진행 바 표시, 완료일 색은 sky blue
  (--destiny-date: 다크 #38bdf8 / 라이트 #0284c7)
- 데스티니 전용 슬롯(destinySimple/destinyWeekly)과 updateDestinySlot/
  resetDestinySlot 액션 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:03:52 +09:00
29fcb39eb3 데스티니 해방 데이터 추가 (하드코딩)
총 45,000 포인트, 8보스 포인트 풀 + 6결전 챕터. 이미지 base url은
liberation/destiny/{boss,quest} 참조. image1/image2 수치 기준.
칼로스는 인게임에 하드가 없어 chaos 70으로 매핑.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:54:28 +09:00
2911dfe3a8 해방 퀘스트 진행 바 색상을 테마별 흑백으로 단순화
기존 보라/빨강 2색에서 다크 테마는 흰색, 라이트 테마는 검정으로 통일.
1차/2차 구분은 동일 색상의 투명도(0.55 vs 1.0, 바는 0.25 vs 0.5)로만 주어
시각적 통일감을 확보.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:32:21 +09:00
28 changed files with 894 additions and 430 deletions

3
backend/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
.git
*.log

7
backend/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

11
backend/constants.js Normal file
View file

@ -0,0 +1,11 @@
// 난이도 ENUM — 모델과 validation 에서 공용
export const DIFFICULTIES = ['easy', 'normal', 'hard', 'chaos', 'extreme'];
// 파티 인원수 범위
export const PARTY_SIZE = { min: 1, max: 6 };
// 심볼 마스터 레벨 범위
export const SYMBOL_MASTER_LEVEL = { min: 2, max: 99 };
// 업로드 파일 크기 상한 (10MB)
export const UPLOAD_FILE_SIZE_LIMIT = 10 * 1024 * 1024;

View file

@ -1,11 +1,12 @@
import { DataTypes } from 'sequelize';
import { sequelize } from '../../lib/db.js';
import { DIFFICULTIES } from '../../constants.js';
export const BossCrystalBossDifficulty = sequelize.define('BossCrystalBossDifficulty', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
boss_id: { type: DataTypes.INTEGER, allowNull: false },
difficulty: {
type: DataTypes.ENUM('easy', 'normal', 'hard', 'chaos', 'extreme'),
type: DataTypes.ENUM(...DIFFICULTIES),
allowNull: false,
},
crystal_price: { type: DataTypes.BIGINT, allowNull: false },

View file

@ -1,16 +1,17 @@
import { Router } from 'express';
import multer from 'multer';
import { Image, Menu } from '../models/index.js';
import { convertAndUpload, deleteFromS3 } from '../services/image.js';
import { convertAndUpload, safeDelete } from '../services/image.js';
import { getPublicUrl } from '../lib/s3.js';
import { sequelize } from '../lib/db.js';
import bossCrystalRouter from './admin/boss-crystal.js';
import symbolRouter from './admin/symbol.js';
import { UPLOAD_FILE_SIZE_LIMIT } from '../constants.js';
const router = Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
limits: { fileSize: UPLOAD_FILE_SIZE_LIMIT },
});
// 관리자 인증 미들웨어
@ -164,13 +165,7 @@ router.post('/images/delete', async (req, res) => {
try {
const images = await Image.findAll({ where: { id: ids } });
await Promise.all(
images.map((img) =>
deleteFromS3(img.path).catch((err) =>
console.warn(`S3 삭제 실패 (${img.path}):`, err.message)
)
)
);
await Promise.all(images.map((img) => safeDelete(img.path)));
await Image.destroy({ where: { id: ids } });
res.json({ success: true, deleted: images.length });

View file

@ -1,14 +1,18 @@
import { Router } from 'express';
import multer from 'multer';
import { BossCrystalBoss, BossCrystalBossDifficulty } from '../../models/index.js';
import { uploadBossImage, deleteBossImage } from '../../services/boss-crystal/image.js';
import { convertAndUploadTo, safeDelete } from '../../services/image.js';
const BOSS_IMAGE_PREFIX = 'crystal/boss';
const bossImagePath = (name) => `${BOSS_IMAGE_PREFIX}/${name}.webp`;
import { getPublicUrl } from '../../lib/s3.js';
import { sequelize } from '../../lib/db.js';
import { UPLOAD_FILE_SIZE_LIMIT, PARTY_SIZE, DIFFICULTIES } from '../../constants.js';
const router = Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
limits: { fileSize: UPLOAD_FILE_SIZE_LIMIT },
});
function serialize(boss) {
@ -39,9 +43,8 @@ function parseDifficulties(raw) {
if (!Array.isArray(arr) || arr.length === 0) {
throw new Error('하나 이상의 난이도가 필요합니다');
}
const valid = ['easy', 'normal', 'hard', 'chaos', 'extreme'];
return arr.map((d) => {
if (!valid.includes(d.difficulty)) throw new Error(`잘못된 난이도: ${d.difficulty}`);
if (!DIFFICULTIES.includes(d.difficulty)) throw new Error(`잘못된 난이도: ${d.difficulty}`);
const price = Number(d.crystal_price);
if (isNaN(price) || price <= 0) throw new Error(`잘못된 가격: ${d.difficulty}`);
return { difficulty: d.difficulty, crystal_price: price };
@ -50,7 +53,9 @@ function parseDifficulties(raw) {
function parseMaxParty(raw) {
const n = Number(raw);
if (isNaN(n) || n < 1 || n > 6) throw new Error('인원수는 1~6이어야 합니다');
if (isNaN(n) || n < PARTY_SIZE.min || n > PARTY_SIZE.max) {
throw new Error(`인원수는 ${PARTY_SIZE.min}~${PARTY_SIZE.max}이어야 합니다`);
}
return n;
}
@ -99,7 +104,7 @@ router.post('/bosses', upload.single('image'), async (req, res) => {
if (existing) return res.status(400).json({ error: '같은 이름의 보스가 이미 존재합니다' });
// 이미지 업로드
const imagePath = await uploadBossImage(req.file.buffer, trimmedName);
const { path: imagePath } = await convertAndUploadTo(req.file.buffer, bossImagePath(trimmedName));
// 마지막 정렬 순서
const max = await BossCrystalBoss.max('sort_order') || 0;
@ -153,15 +158,14 @@ router.patch('/bosses/:id', upload.single('image'), async (req, res) => {
// 새 이미지 업로드 또는 이름 변경 시 이미지 재업로드
if (req.file) {
const oldPath = boss.image_path;
newImagePath = await uploadBossImage(req.file.buffer, newName);
const uploaded = await convertAndUploadTo(req.file.buffer, bossImagePath(newName));
newImagePath = uploaded.path;
if (oldPath && oldPath !== newImagePath) {
await deleteBossImage(oldPath);
await safeDelete(oldPath);
}
} else if (newName !== boss.name && boss.image_path) {
// 이름만 변경 - 기존 이미지를 새 경로로 복사하는 대신 이름 기반 경로 업데이트
// 간단하게 처리: 기존 키를 새 키로 교체할 수 없으니, 이미지가 없으면 그대로, 있으면 path만 갱신
newImagePath = `crystal/boss/${newName}.webp`;
// 실제로 이름이 바뀌면 이미지를 다시 올려달라고 하는 게 안전. 현재는 path만 업데이트하고 추후 다음 업로드 시 새 경로로 저장됨
// 이름 변경 시 path만 갱신 (실제 파일은 다음 이미지 업로드 때 새 경로로 저장됨)
newImagePath = bossImagePath(newName);
}
await sequelize.transaction(async (tx) => {
@ -194,7 +198,7 @@ router.delete('/bosses/:id', async (req, res) => {
if (!boss) return res.status(404).json({ error: '보스를 찾을 수 없습니다' });
if (boss.image_path) {
await deleteBossImage(boss.image_path);
await safeDelete(boss.image_path);
}
await boss.destroy();
res.json({ success: true });

View file

@ -1,14 +1,15 @@
import { Router } from 'express';
import multer from 'multer';
import { Symbol, SymbolLevel } from '../../models/index.js';
import { convertAndUploadTo, deleteFromS3 } from '../../services/image.js';
import { convertAndUploadTo, safeDelete } from '../../services/image.js';
import { getPublicUrl } from '../../lib/s3.js';
import { sequelize } from '../../lib/db.js';
import { UPLOAD_FILE_SIZE_LIMIT, SYMBOL_MASTER_LEVEL } from '../../constants.js';
const router = Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
limits: { fileSize: UPLOAD_FILE_SIZE_LIMIT },
});
const VALID_TYPES = ['아케인', '어센틱', '그랜드 어센틱'];
@ -70,7 +71,9 @@ function validateBasic({ type, region, max_level }) {
const r = String(region || '').trim();
if (!r) throw new Error('지역 이름을 입력해주세요');
const ml = Number(max_level);
if (!ml || ml < 2 || ml > 99) throw new Error('만렙은 2~99 사이여야 합니다');
if (!ml || ml < SYMBOL_MASTER_LEVEL.min || ml > SYMBOL_MASTER_LEVEL.max) {
throw new Error(`만렙은 ${SYMBOL_MASTER_LEVEL.min}~${SYMBOL_MASTER_LEVEL.max} 사이여야 합니다`);
}
return { type, region: r, max_level: ml };
}
@ -164,7 +167,7 @@ router.patch('/symbols/:id', upload.single('image'), async (req, res) => {
imageKey = imagePath(basic.type, basic.region);
await convertAndUploadTo(req.file.buffer, imageKey);
if (row.image && row.image !== imageKey) {
try { await deleteFromS3(row.image); } catch { /* ignore */ }
await safeDelete(row.image);
}
} else if (basic.type !== row.type || basic.region !== row.region) {
// 이름/종류 변경 시 새 경로로 rename 대체 불가 → 기존 키 유지
@ -208,7 +211,7 @@ router.delete('/symbols/:id', async (req, res) => {
if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' });
const key = row.image;
await row.destroy();
if (key) { try { await deleteFromS3(key); } catch { /* ignore */ } }
if (key) await safeDelete(key);
res.json({ success: true });
} catch (err) {
console.error('심볼 삭제 오류:', err.message);

View file

@ -1,26 +0,0 @@
import sharp from 'sharp';
import { uploadObject, deleteObject } from '../../lib/s3.js';
const BOSS_IMAGE_PREFIX = 'crystal/boss';
/**
* 보스 이미지를 webp로 변환하고 RustFS의 crystal/boss/{name}.webp에 업로드
* @param {Buffer} buffer
* @param {string} bossName
* @returns {Promise<string>} S3 (: crystal/boss/검은마법사.webp)
*/
export async function uploadBossImage(buffer, bossName) {
const webp = await sharp(buffer).webp({ quality: 90 }).toBuffer();
const path = `${BOSS_IMAGE_PREFIX}/${bossName}.webp`;
await uploadObject(path, webp, 'image/webp');
return path;
}
export async function deleteBossImage(path) {
if (!path) return;
try {
await deleteObject(path);
} catch (err) {
console.warn(`보스 이미지 삭제 실패 (${path}):`, err.message);
}
}

View file

@ -30,6 +30,16 @@ export async function deleteFromS3(path) {
await deleteObject(path);
}
// 삭제 실패해도 흐름을 끊지 않는 버전 (이전 이미지 정리 등에 사용)
export async function safeDelete(path) {
if (!path) return;
try {
await deleteObject(path);
} catch (err) {
console.warn(`S3 삭제 실패 (${path}):`, err.message);
}
}
/**
* 지정한 경로로 webp 변환 업로드 (덮어쓰기)
* @param {Buffer} buffer - 원본 이미지 버퍼

View file

@ -1,36 +1,26 @@
services:
frontend:
build: ./frontend
container_name: maplestory-frontend
image: node:22-alpine
working_dir: /app
volumes:
- ./frontend:/app
- frontend_modules:/app/node_modules
command: sh -c "npm install --legacy-peer-deps && npm run dev"
labels:
- "com.centurylinklabs.watchtower.enable=false"
networks:
- caddy
restart: unless-stopped
backend:
build: ./backend
container_name: maplestory-backend
image: node:22-alpine
working_dir: /app
volumes:
- ./backend:/app
- backend_modules:/app/node_modules
command: sh -c "npm install && npm run dev"
env_file: .env
environment:
- NODE_ENV=production
labels:
- "com.centurylinklabs.watchtower.enable=false"
networks:
- caddy
- db
- app
volumes:
frontend_modules:
backend_modules:
restart: unless-stopped
networks:
caddy:

4
frontend/.dockerignore Normal file
View file

@ -0,0 +1,4 @@
node_modules
dist
.git
*.log

15
frontend/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
# 빌드 단계
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --include=dev --legacy-peer-deps
COPY . .
RUN npm run build
# 정적 파일 서빙 단계
FROM node:22-alpine
WORKDIR /app
RUN npm install -g serve
COPY --from=builder /app/dist ./dist
EXPOSE 5173
CMD ["serve", "-s", "dist", "-l", "5173"]

View file

@ -230,9 +230,10 @@ export default function Layout() {
style={{ color: 'var(--text-strong)' }}
>
<header
className="sticky top-0 z-20 border-b backdrop-blur-md shrink-0"
className="sticky top-0 z-20 border-b shrink-0"
style={{
borderColor: 'var(--header-border)',
background: 'var(--bg-from)',
}}
>
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-4">

View file

@ -102,7 +102,7 @@ export default function BossSelector({ characterName, bosses, selections, onChan
className="shrink-0 w-11 h-11 rounded-lg overflow-hidden"
style={{ background: 'var(--surface-nested)' }}
>
<img src={boss.image_url || '/default.png'} alt={boss.name} className="w-full h-full object-cover" />
<img src={boss.image_url || '/default.png'} alt={boss.name} loading="lazy" decoding="async" className="w-full h-full object-cover" />
</div>
<span className="text-base font-medium leading-tight whitespace-nowrap overflow-hidden text-ellipsis">{boss.name}</span>
</div>

View file

@ -48,6 +48,8 @@ function CharacterContent({ char, selections, bosses }) {
className="w-full h-full object-contain scale-[3] origin-center select-none"
style={{ imageRendering: 'pixelated' }}
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<span className="text-4xl" style={{ color: 'var(--text-dim)' }}>?</span>
@ -79,7 +81,7 @@ function CharacterContent({ char, selections, bosses }) {
borderColor: 'var(--panel-border)',
}}
>
<img src={item.boss.image_url || '/default.png'} alt="" draggable={false} className="w-full h-full object-cover select-none" />
<img src={item.boss.image_url || '/default.png'} alt="" draggable={false} loading="lazy" decoding="async" className="w-full h-full object-cover select-none" />
</div>
<div className="flex justify-center">
<div

View file

@ -14,7 +14,6 @@ export const GENESIS_CHAPTERS = [
// 퀘스트 이미지 경로 (제네시스)
export const QUEST_BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/quest'
export const QUEST_BTBOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/quest/btboss'
// 주간/월간 보스 초상화 (해방용)
export const LIBERATION_BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/boss'
@ -90,6 +89,82 @@ export const MONTHLY_BOSSES = [
export const BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/crystal/boss'
export const DIFFICULTY_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/crystal/difficulty'
// ── 데스티니 해방 (6단계, 총 45,000) ──
// phase 1: 선택받은 세렌 / 감시자 칼로스 / 카링
// phase 2: 최초의 대적자 / 림보 / 발드릭스
export const DESTINY_CHAPTERS = [
{ idx: 0, phase: 1, boss: '선택받은 세렌',
quest_name: '결전, 선택받은 세렌', boss_label: '하드 세렌',
penalty: '최종 데미지 80% 감소', required: 2000 },
{ idx: 1, phase: 1, boss: '감시자 칼로스',
quest_name: '결전, 감시자 칼로스', boss_label: '카오스 칼로스',
penalty: '데스 카운트 3으로 감소', required: 2500 },
{ idx: 2, phase: 1, boss: '카링',
quest_name: '결전, 사도 카링', boss_label: '하드 카링',
penalty: '최종 데미지 20% 증가', required: 3000 },
{ idx: 3, phase: 2, boss: '최초의 대적자',
quest_name: '결전, 최초의 대적자', boss_label: '하드 대적자',
penalty: '최종 데미지 20% 감소 / 피격 시 질서의 힘 감소량 50% 증가', required: 10000 },
{ idx: 4, phase: 2, boss: '림보',
quest_name: '결전, 사도 림보', boss_label: '하드 림보',
penalty: '침식 수치 800으로 시작', required: 12500 },
{ idx: 5, phase: 2, boss: '발드릭스',
quest_name: '결전, 사도 발드릭스', boss_label: '하드 발드릭스',
penalty: '피격 시 받는 데미지 30% 증가', required: 15000 },
]
export const DESTINY_TOTAL = DESTINY_CHAPTERS.reduce((s, c) => s + c.required, 0) // 45,000
// 데스티니 포인트 획득 보스 (대적자의 결의 / 1인격파 기준)
export const DESTINY_BOSSES = [
{ key: 'seren', name: '선택받은 세렌', image: '선택받은 세렌.webp',
difficulties: [
{ key: 'hard', label: '하드', points: 6 },
{ key: 'extreme', label: '익스트림', points: 80 },
] },
{ key: 'kalos', name: '감시자 칼로스', image: '감시자 칼로스.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 10 },
{ key: 'chaos', label: '카오스', points: 70 },
{ key: 'extreme', label: '익스트림', points: 400 },
] },
{ key: 'kaling', name: '카링', image: '카링.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 20 },
{ key: 'hard', label: '하드', points: 160 },
{ key: 'extreme', label: '익스트림', points: 1200 },
] },
{ key: 'limbo', name: '림보', image: '림보.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 120 },
{ key: 'hard', label: '하드', points: 360 },
] },
{ key: 'baldrix', name: '발드릭스', image: '발드릭스.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 150 },
{ key: 'hard', label: '하드', points: 450 },
] },
{ key: 'firstadversary', name: '최초의 대적자', image: '최초의 대적자.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 15 },
{ key: 'hard', label: '하드', points: 120 },
{ key: 'extreme', label: '익스트림', points: 500 },
] },
{ key: 'brilliantvillain', name: '찬란한 흉성', image: '찬란한 흉성.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 20 },
{ key: 'hard', label: '하드', points: 380 },
] },
{ key: 'jupiter', name: '유피테르', image: '유피테르.webp',
difficulties: [
{ key: 'normal', label: '노말', points: 160 },
{ key: 'hard', label: '하드', points: 500 },
] },
]
export const DESTINY_BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/destiny/boss'
export const DESTINY_QUEST_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/destiny/quest'
// 파티 인원수로 점수 분배 (버림)
export function calcPoints(basePoints, partySize) {
return Math.floor(basePoints / partySize)

View file

@ -0,0 +1,217 @@
import { useState, useMemo } from 'react'
import dayjs from 'dayjs'
import {
DESTINY_CHAPTERS,
DESTINY_TOTAL,
DESTINY_QUEST_IMAGE_BASE,
DESTINY_BOSSES,
DESTINY_BOSS_IMAGE_BASE,
formatDate,
} from '../data'
import { useLiberationStore, makeEmptyDestinyWeekly } from '../store'
import { calcWeekPoints, calcDoneEarn, computeCompletionDate } from '../utils'
import ProgressBar from './components/ProgressBar'
import QuestSelector from './components/QuestSelector'
import PointsInput from './components/PointsInput'
import WeeklyDefault from './components/WeeklyDefault'
import DatePicker from '../../../components/common/DatePicker'
import ConfirmDialog from '../../../components/common/ConfirmDialog'
export default function Destiny() {
const calcMode = useLiberationStore((s) => s.destinyCalcMode)
const setCalcMode = useLiberationStore((s) => s.setDestinyCalcMode)
const state = useLiberationStore((s) => s.destinyCalcMode === 'weekly' ? s.destinyWeekly : s.destinySimple)
const updateSlot = useLiberationStore((s) => s.updateDestinySlot)
const resetSlot = useLiberationStore((s) => s.resetDestinySlot)
const setState = (updater) => updateSlot(updater)
// : required
const priorConsumed = DESTINY_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 < DESTINY_CHAPTERS.length && cascadeRemain >= DESTINY_CHAPTERS[cascadeIdx].required) {
cascadeConsumed += DESTINY_CHAPTERS[cascadeIdx].required
cascadeRemain -= DESTINY_CHAPTERS[cascadeIdx].required
cascadeIdx++
}
const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain
const alreadyDone = initialAccumulated >= DESTINY_TOTAL
const remaining = Math.max(DESTINY_TOTAL - initialAccumulated, 0)
const weeklyEarn = calcWeekPoints(state.weekly, DESTINY_BOSSES)
const doneEarn = calcDoneEarn(state.weekly, DESTINY_BOSSES)
const headerWeekly = calcMode === 'weekly'
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config, DESTINY_BOSSES), 0)
: weeklyEarn
const completionDate = useMemo(
() => computeCompletionDate({
calcMode, state, alreadyDone, remaining,
weeklyEarn, doneEarn,
monthlyEarn: 0,
monthlyDoneThisMonth: false,
bosses: DESTINY_BOSSES,
monthlyBoss: null,
makeEmptyConfig: makeEmptyDestinyWeekly,
}),
[calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn],
)
const isDone = completionDate !== null
const [resetOpen, setResetOpen] = useState(false)
const doReset = () => {
resetSlot()
setResetOpen(false)
}
return (
<>
{/* 계산 모드 탭 */}
<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
chapters={DESTINY_CHAPTERS}
imageBase={DESTINY_QUEST_IMAGE_BASE}
startChapter={state.startChapter}
currentPoints={state.currentPoints}
completionDate={isDone ? formatDate(completionDate) : null}
completionColor="var(--destiny-date)"
/>
{/* 현재 진행 상태 입력 */}
<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
chapters={DESTINY_CHAPTERS}
imageBase={DESTINY_QUEST_IMAGE_BASE}
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={20000}
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)',
}}
>
/ {(DESTINY_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
<WeeklyDefault
bosses={DESTINY_BOSSES}
imageBase={DESTINY_BOSS_IMAGE_BASE}
makeEmptyConfig={makeEmptyDestinyWeekly}
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={headerWeekly}
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
/>
</>
)
}

View file

@ -0,0 +1,243 @@
import { useState, useMemo } from 'react'
import dayjs from 'dayjs'
import {
GENESIS_CHAPTERS,
GENESIS_TOTAL,
WEEKLY_BOSSES,
MONTHLY_BOSSES,
QUEST_BOSS_IMAGE_BASE,
LIBERATION_BOSS_IMAGE_BASE,
formatDate,
} from '../data'
import { useLiberationStore, makeEmptyWeekly } 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'
export default function Genesis() {
const calcMode = useLiberationStore((s) => s.genesisCalcMode)
const state = useLiberationStore((s) => s[s.genesisCalcMode])
const setCalcMode = useLiberationStore((s) => s.setGenesisCalcMode)
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="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
chapters={GENESIS_CHAPTERS}
imageBase={QUEST_BOSS_IMAGE_BASE}
startChapter={state.startChapter}
currentPoints={state.currentPoints}
completionDate={isDone ? formatDate(completionDate) : null}
completionColor="var(--genesis-date)"
/>
{/* 현재 진행 상태 입력 */}
<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
chapters={GENESIS_CHAPTERS}
imageBase={QUEST_BOSS_IMAGE_BASE}
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
bosses={WEEKLY_BOSSES}
monthlyBosses={MONTHLY_BOSSES}
imageBase={LIBERATION_BOSS_IMAGE_BASE}
makeEmptyConfig={makeEmptyWeekly}
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
/>
</>
)
}

View file

@ -1,29 +1,10 @@
import { useState, useLayoutEffect, useMemo } from 'react'
import { useLayoutEffect } 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'
import { useLiberationStore } from '../store'
import Genesis from './Genesis'
import Destiny from './Destiny'
export default function Liberation() {
const { setFullscreen } = useLayout()
@ -32,7 +13,8 @@ export default function Liberation() {
return () => setFullscreen(false)
}, [setFullscreen])
const [liberationType, setLiberationType] = useState('genesis') // 'genesis' | 'destiny'
const liberationType = useLiberationStore((s) => s.liberationType)
const setLiberationType = useLiberationStore((s) => s.setLiberationType)
const genesisImg = useQuery({
queryKey: ['image', '제네시스 스태프'],
@ -45,72 +27,6 @@ export default function Liberation() {
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">
{/* 해방 종류 탭 */}
@ -144,156 +60,7 @@ export default function Liberation() {
})}
</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
/>
{liberationType === 'genesis' ? <Genesis /> : <Destiny />}
</div>
)
}

View file

@ -1,5 +1,3 @@
import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../../data'
const DOW = ['일', '월', '화', '수', '목', '금', '토']
function formatKoreanDate(s) {
const [y, m, d] = s.split('-')
@ -7,8 +5,15 @@ function formatKoreanDate(s) {
return `${y}${m}${d}일 (${dow})`
}
export default function ProgressBar({ startChapter, currentPoints, completionDate }) {
const chapterStates = GENESIS_CHAPTERS.map((c) => {
export default function ProgressBar({
chapters,
imageBase,
startChapter,
currentPoints,
completionDate,
completionColor = 'var(--warning-text-bright)',
}) {
const chapterStates = chapters.map((c) => {
if (c.idx < startChapter) return { chapter: c, status: 'done', current: c.required }
if (c.idx === startChapter) {
const filled = Math.min(currentPoints, c.required)
@ -41,7 +46,7 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
status === 'pending' ? 'opacity-50' : ''
}`}>
<img
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.webp`}
src={`${imageBase}/${chapter.boss}.webp`}
alt={chapter.boss}
className={`block w-full h-full object-cover ${status === 'pending' ? 'grayscale' : ''}`}
/>
@ -101,7 +106,7 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
<span style={{ color: 'var(--text-dim)' }}>·</span>
<span
className="text-xl font-bold tabular-nums"
style={{ color: 'var(--warning-text-bright)' }}
style={{ color: completionColor }}
>
{completionDate ? formatKoreanDate(completionDate) : <span className="font-normal" style={{ color: 'var(--text-dim)' }}>미정</span>}
</span>

View file

@ -1,12 +1,12 @@
import { useState, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { GENESIS_CHAPTERS, QUEST_BOSS_IMAGE_BASE } from '../../data'
/**
* 진행 중인 퀘스트 드롭다운
* - 보스 초상화 + 이름 텍스트
* 진행 중인 퀘스트 드롭다운 (보스 초상화 + 이름)
* @param {Array} chapters - { idx, boss, ... }[]
* @param {string} imageBase - 보스 초상화 S3 base URL
*/
export default function QuestSelector({ value, onChange }) {
export default function QuestSelector({ chapters, imageBase, value, onChange }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
@ -19,7 +19,7 @@ export default function QuestSelector({ value, onChange }) {
return () => document.removeEventListener('mousedown', handler)
}, [open])
const selected = GENESIS_CHAPTERS[value]
const selected = chapters[value]
return (
<div ref={ref} className="relative">
@ -38,7 +38,7 @@ export default function QuestSelector({ value, onChange }) {
style={{ background: 'var(--surface-nested)' }}
>
<img
src={`${QUEST_BOSS_IMAGE_BASE}/${selected.boss}.webp`}
src={`${imageBase}/${selected.boss}.webp`}
alt=""
className="w-full h-full object-cover"
/>
@ -69,7 +69,7 @@ export default function QuestSelector({ value, onChange }) {
boxShadow: 'var(--popup-shadow)',
}}
>
{GENESIS_CHAPTERS.map((chapter) => {
{chapters.map((chapter) => {
const isSelected = chapter.idx === value
return (
<button
@ -86,7 +86,7 @@ export default function QuestSelector({ value, onChange }) {
style={{ background: 'var(--surface-nested)' }}
>
<img
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.webp`}
src={`${imageBase}/${chapter.boss}.webp`}
alt=""
className="w-full h-full object-cover"
/>

View file

@ -1,7 +1,7 @@
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 { 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 }
@ -16,7 +16,7 @@ function diffLabel(d, party) {
)
}
export function BossRow({ boss, sel, onChange, monthly = false, showDone = true }) {
export function BossRow({ boss, sel, onChange, imageBase, monthly = false, showDone = true }) {
const disabled = sel.difficulty === 'none'
const difficultyOptions = [NONE_DIFFICULTY, ...boss.difficulties]
.map((d) => ({ value: d.key, label: diffLabel(d, sel.party) }))
@ -24,7 +24,7 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
return (
<div className="flex items-center gap-3 rounded-lg px-3 h-16">
<Tooltip text={boss.name}>
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt="" className="w-10 h-10 rounded-md object-cover shrink-0" />
<img src={`${imageBase}/${boss.image}`} alt="" className="w-10 h-10 rounded-md object-cover shrink-0" />
</Tooltip>
<span className="text-base font-semibold flex-1 truncate">
{boss.name}
@ -61,6 +61,7 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
type="button"
disabled={disabled}
onClick={() => onChange({ done: !sel.done })}
title="이번 주 해당 난이도를 이미 클리어했는지 여부"
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border"
style={disabled ? {
borderColor: 'var(--panel-border)',
@ -81,7 +82,23 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
)
}
export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMonthly, remaining, mode = 'simple', startDate, weeks, onChangeWeeks }) {
export default function WeeklyDefault({
bosses,
monthlyBosses = [],
imageBase,
makeEmptyConfig,
weekly,
onChange,
totalWeekly,
totalMonthly = 0,
remaining,
mode = 'simple',
startDate,
weeks,
onChangeWeeks,
hasScheduler = true,
label = '주간 보스 설정',
}) {
const updateBoss = (key, patch) => {
onChange({ ...weekly, bosses: { ...weekly.bosses, [key]: { ...weekly.bosses[key], ...patch } } })
}
@ -99,13 +116,17 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
}}
>
<div className="flex items-center justify-between">
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>주간 보스 설정</div>
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>{label}</div>
<div className="text-sm tabular-nums">
{mode === 'weekly' ? (
<>
<span className="font-semibold" style={{ color: 'var(--accent-bright)' }}>{totalWeekly}</span>
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>+</span>
<span className="font-semibold" style={{ color: 'var(--warning-text-bright)' }}>{totalMonthly}</span>
{monthlyBosses.length > 0 && (
<>
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>+</span>
<span className="font-semibold" style={{ color: 'var(--warning-text-bright)' }}>{totalMonthly}</span>
</>
)}
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>/</span>
<span className="font-semibold" style={{ color: 'var(--text-emphasis)' }}>{(remaining ?? 0).toLocaleString()}</span>
</>
@ -115,9 +136,9 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
</div>
</div>
{mode === 'simple' ? (
{mode === 'simple' || !hasScheduler ? (
<div>
{WEEKLY_BOSSES.map((boss, i) => (
{bosses.map((boss, i) => (
<div
key={boss.key}
className={i > 0 ? 'border-t' : ''}
@ -127,10 +148,11 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
boss={boss}
sel={weekly.bosses[boss.key]}
onChange={(patch) => updateBoss(boss.key, patch)}
imageBase={imageBase}
/>
</div>
))}
{MONTHLY_BOSSES.map((boss) => (
{monthlyBosses.map((boss) => (
<div
key={boss.key}
className="border-t"
@ -140,6 +162,7 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
boss={boss}
sel={weekly.blackMage}
onChange={updateBlackMage}
imageBase={imageBase}
monthly
/>
</div>
@ -147,6 +170,10 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
</div>
) : (
<WeeklyScheduler
bosses={bosses}
monthlyBoss={monthlyBosses[0] ?? null}
imageBase={imageBase}
makeEmptyConfig={makeEmptyConfig}
startDate={startDate}
weeks={weeks}
onChangeWeeks={onChangeWeeks}

View file

@ -1,7 +1,5 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
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'
@ -18,7 +16,7 @@ const DIFF_BADGE = {
extreme: { label: 'X', color: '#f59e0b', border: 'rgba(245,158,11,0.5)', bg: 'rgba(245,158,11,0.2)' },
}
function BossAvatar({ boss, difficulty, size = 40 }) {
function BossAvatar({ boss, imageBase, difficulty, size = 40 }) {
const badge = DIFF_BADGE[difficulty]
const enabled = difficulty && difficulty !== 'none'
return (
@ -32,7 +30,7 @@ function BossAvatar({ boss, difficulty, size = 40 }) {
borderColor: 'var(--panel-border)',
}}
>
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-full h-full object-cover" />
<img src={`${imageBase}/${boss.image}`} alt={boss.name} className="w-full h-full object-cover" />
</div>
<div
className="text-[10px] font-bold leading-none rounded flex items-center justify-center border"
@ -49,7 +47,7 @@ function BossAvatar({ boss, difficulty, size = 40 }) {
)
}
function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek, bosses, monthlyBoss, imageBase }) {
const updateBoss = (key, patch) => {
onChange({ ...config, bosses: { ...config.bosses, [key]: { ...config.bosses[key], ...patch } } })
}
@ -61,7 +59,7 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
return (
<div>
{WEEKLY_BOSSES.map((boss, i) => (
{bosses.map((boss, i) => (
<div
key={boss.key}
className={i > 0 ? 'border-t' : ''}
@ -71,23 +69,27 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
boss={boss}
sel={config.bosses[boss.key]}
onChange={(patch) => updateBoss(boss.key, patch)}
imageBase={imageBase}
showDone={isCurrent}
/>
</div>
))}
<div
className={`border-t ${blackmageLocked ? 'opacity-40 pointer-events-none' : ''}`}
style={{ borderColor: 'var(--row-divider)' }}
>
<BossRow
boss={MONTHLY_BOSSES[0]}
sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage}
onChange={updateBlackMage}
monthly
showDone={isCurrent}
/>
</div>
{blackmageLocked && (
{monthlyBoss && (
<div
className={`border-t ${blackmageLocked ? 'opacity-40 pointer-events-none' : ''}`}
style={{ borderColor: 'var(--row-divider)' }}
>
<BossRow
boss={monthlyBoss}
sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage}
onChange={updateBlackMage}
imageBase={imageBase}
monthly
showDone={isCurrent}
/>
</div>
)}
{monthlyBoss && blackmageLocked && (
<div
className="text-[11px] px-3 py-2"
style={{ color: 'var(--warning-text)' }}
@ -99,10 +101,18 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
)
}
export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) {
export default function WeeklyScheduler({
bosses,
monthlyBoss = null,
imageBase,
makeEmptyConfig,
startDate,
weeks: weeksProp,
onChangeWeeks,
}) {
const weeks = weeksProp && weeksProp.length > 0
? weeksProp
: [{ id: 1, config: makeEmptyWeekly() }]
: [{ id: 1, config: makeEmptyConfig() }]
const setWeeks = (updater) => {
const next = typeof updater === 'function' ? updater(weeks) : updater
onChangeWeeks?.(next)
@ -114,13 +124,13 @@ 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)) : makeEmptyWeekly()
const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyConfig()
// done
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
if (base.blackMage) base.blackMage.done = false
//
if (startDate && base.blackMage?.difficulty && base.blackMage.difficulty !== 'none') {
//
if (monthlyBoss && startDate && base.blackMage?.difficulty && base.blackMage.difficulty !== 'none') {
const newIdx = prev.length + 1
const newMonth = getWeekRange(startDate, newIdx).start.format('YYYY-MM')
const existsInSameMonth = prev.some((p, i) => {
@ -146,9 +156,9 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
setWeeks((prev) => prev.map((w) => (w.id === id ? { ...w, config } : w)))
}
// :
// :
const monthlyLocks = (() => {
if (!startDate) return {}
if (!monthlyBoss || !startDate) return {}
const claimed = {} // month -> weekNum (1-based)
weeks.forEach((w, idx) => {
const diff = w.config.blackMage?.difficulty
@ -166,9 +176,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
weeks.forEach((w, idx) => {
const r = getWeekRange(startDate, idx + 1)
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
//
if (months.some((m) => claimed[m] === idx + 1)) return
//
if (months.every((m) => m in claimed)) {
locks[idx] = claimed[months[0]] ?? claimed[months[1]]
}
@ -181,8 +189,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
{weeks.map((w, idx) => {
const n = idx + 1
const isOpen = expanded === w.id
const isCurrent = idx === 0 // : ( )
// monthlyLocks
const isCurrent = idx === 0
const monthlyLockedByWeek = monthlyLocks[idx] ?? null
return (
<div
@ -218,15 +225,24 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
)}
<div className="flex-1 flex items-center gap-2">
{WEEKLY_BOSSES.map((b) => (
<BossAvatar key={b.key} boss={b} difficulty={w.config.bosses[b.key]?.difficulty} size={40} />
{bosses.map((b) => (
<BossAvatar key={b.key} boss={b} imageBase={imageBase} difficulty={w.config.bosses[b.key]?.difficulty} size={40} />
))}
<BossAvatar boss={MONTHLY_BOSSES[0]} difficulty={monthlyLockedByWeek != null ? 'none' : w.config.blackMage?.difficulty} size={40} />
{monthlyBoss && (
<BossAvatar
boss={monthlyBoss}
imageBase={imageBase}
difficulty={monthlyLockedByWeek != null ? 'none' : w.config.blackMage?.difficulty}
size={40}
/>
)}
</div>
{(() => {
const weeklySum = calcWeeklySum(w.config)
const monthlySum = monthlyLockedByWeek != null ? 0 : bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
const weeklySum = calcWeeklySum(w.config, bosses)
const monthlySum = !monthlyBoss || monthlyLockedByWeek != null
? 0
: bossEarn(monthlyBoss, w.config.blackMage)
return (
<div className="text-right shrink-0 pr-1 tabular-nums leading-tight">
<div className="text-base font-bold" style={{ color: 'var(--accent-bright)' }}>+{weeklySum}</div>
@ -284,6 +300,9 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
onChange={(c) => updateWeek(w.id, c)}
isCurrent={isCurrent}
monthlyLockedByWeek={monthlyLockedByWeek}
bosses={bosses}
monthlyBoss={monthlyBoss}
imageBase={imageBase}
/>
</div>
</motion.div>

View file

@ -1,7 +1,7 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import dayjs from 'dayjs'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, todayKST } from './data'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, DESTINY_BOSSES, todayKST } from './data'
function makeEmptyWeekly() {
const bosses = {}
@ -14,6 +14,14 @@ function makeEmptyWeekly() {
}
}
function makeEmptyDestinyWeekly() {
const bosses = {}
DESTINY_BOSSES.forEach((b) => {
bosses[b.key] = { difficulty: 'none', party: 1, done: false }
})
return { bosses }
}
function makeInitialSlot() {
return {
startChapter: 0,
@ -24,28 +32,77 @@ function makeInitialSlot() {
}
}
function makeInitialDestinySlot() {
return {
startChapter: 0,
currentPoints: 0,
startDate: dayjs(todayKST()).toISOString(),
weekly: makeEmptyDestinyWeekly(),
schedulerWeeks: [{ id: 1, config: makeEmptyDestinyWeekly() }],
}
}
/**
* 해방 계산기 상태
* calcMode: 'simple' | 'weekly'
* simple / weekly: 모드 독립 슬롯
* calcMode: 'simple' | 'weekly' (제네시스/데스티니가 공유)
* simple / weekly: 제네시스 모드별 독립 슬롯
* destinySimple / destinyWeekly: 데스티니 모드별 독립 슬롯
*/
export const useLiberationStore = create(persist(
(set) => ({
calcMode: 'simple',
liberationType: 'genesis', // 'genesis' | 'destiny'
genesisCalcMode: 'simple',
destinyCalcMode: 'simple',
simple: makeInitialSlot(),
weekly: makeInitialSlot(),
destinySimple: makeInitialDestinySlot(),
destinyWeekly: makeInitialDestinySlot(),
setCalcMode: (mode) => set({ calcMode: mode }),
setLiberationType: (type) => set({ liberationType: type }),
setGenesisCalcMode: (mode) => set({ genesisCalcMode: mode }),
setDestinyCalcMode: (mode) => set({ destinyCalcMode: mode }),
updateSlot: (patch) => set((s) => ({
[s.calcMode]: typeof patch === 'function'
? patch(s[s.calcMode])
: { ...s[s.calcMode], ...patch },
[s.genesisCalcMode]: typeof patch === 'function'
? patch(s[s.genesisCalcMode])
: { ...s[s.genesisCalcMode], ...patch },
})),
resetSlot: () => set((s) => ({ [s.calcMode]: makeInitialSlot() })),
resetSlot: () => set((s) => ({ [s.genesisCalcMode]: makeInitialSlot() })),
updateDestinySlot: (patch) => set((s) => {
const key = s.destinyCalcMode === 'weekly' ? 'destinyWeekly' : 'destinySimple'
return {
[key]: typeof patch === 'function' ? patch(s[key]) : { ...s[key], ...patch },
}
}),
resetDestinySlot: () => set((s) => {
const key = s.destinyCalcMode === 'weekly' ? 'destinyWeekly' : 'destinySimple'
return { [key]: makeInitialDestinySlot() }
}),
}),
{ name: 'maple-liberation' },
{
name: 'maple-liberation',
version: 2,
migrate: (persisted) => {
if (!persisted) return persisted
// 데스티니 슬롯에 weekly/schedulerWeeks 필드가 없으면 빈 값으로 채움
const fill = (slot) => {
if (!slot) return slot
return {
...slot,
weekly: slot.weekly || makeEmptyDestinyWeekly(),
schedulerWeeks: slot.schedulerWeeks || [{ id: 1, config: makeEmptyDestinyWeekly() }],
}
}
return {
...persisted,
destinySimple: fill(persisted.destinySimple),
destinyWeekly: fill(persisted.destinyWeekly),
}
},
},
))
export { makeEmptyWeekly, makeInitialSlot }
export { makeEmptyWeekly, makeEmptyDestinyWeekly, makeInitialSlot, makeInitialDestinySlot }

View file

@ -11,17 +11,17 @@ export function bossEarn(boss, sel) {
return calcPoints(d.points, sel.party)
}
export function calcWeekPoints(weekData) {
export function calcWeekPoints(weekData, bosses = WEEKLY_BOSSES) {
let points = 0
WEEKLY_BOSSES.forEach((b) => {
bosses.forEach((b) => {
points += bossEarn(b, weekData.bosses[b.key])
})
return points
}
export function calcDoneEarn(weekData) {
export function calcDoneEarn(weekData, bosses = WEEKLY_BOSSES) {
let points = 0
WEEKLY_BOSSES.forEach((b) => {
bosses.forEach((b) => {
const sel = weekData.bosses[b.key]
if (sel?.done) points += bossEarn(b, sel)
})
@ -64,6 +64,9 @@ export function getSchedulerWeekRange(startDateStr, weekIdx) {
export function computeCompletionDate({
calcMode, state, alreadyDone, remaining,
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
bosses = WEEKLY_BOSSES,
monthlyBoss = MONTHLY_BOSSES[0],
makeEmptyConfig = makeEmptyWeekly,
}) {
if (alreadyDone) return todayKST()
if (remaining <= 0) return dayjs(state.startDate).tz(KST).startOf('day').toDate()
@ -78,50 +81,52 @@ export function computeCompletionDate({
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
// 1주차: 시작일 당일에 (주간 - done) 적립
const week1Cfg = sw[0]?.config || makeEmptyWeekly()
const w1Weekly = calcWeekPoints(week1Cfg)
const w1Done = calcDoneEarn(week1Cfg)
const week1Cfg = sw[0]?.config || makeEmptyConfig()
const w1Weekly = calcWeekPoints(week1Cfg, bosses)
const w1Done = calcDoneEarn(week1Cfg, bosses)
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) })
const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyConfig()
events.push({ date: nextThu, amount: calcWeekPoints(cfg, bosses) })
nextThu = nextThu.add(1, 'week')
}
// 검은 마법사: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립
// 월간 보스: 슬롯 배정에 따라 해당 주차 첫날(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,
if (monthlyBoss) {
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(monthlyBoss, w.config.blackMage),
done: !!w.config.blackMage.done,
}
return
}
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 })
})
})
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
const lastBmEarn = monthlyBoss && lastCfg ? bossEarn(monthlyBoss, lastCfg.blackMage) : 0
if (lastBmEarn > 0) {
const lastWeekStart = sw.length === 1
? startKST
@ -166,9 +171,20 @@ export function computeCompletionDate({
events.sort((a, b) => a.date.diff(b.date))
let cumulative = 0
let lastEventDate = startKST
for (const e of events) {
cumulative += e.amount
lastEventDate = e.date
if (cumulative >= remaining) return e.date.toDate()
}
return null
// 10년 loop 내에 도달 못 한 경우: 정상 상태 주간 획득량으로 선형 외삽
// 단순 모드: weeklyEarn / 주차별 모드: 마지막 주차 설정의 주간 합
const steadyWeekly = calcMode === 'simple'
? weeklyEarn
: calcWeekPoints((state.schedulerWeeks || []).slice(-1)[0]?.config || makeEmptyConfig(), bosses)
if (steadyWeekly <= 0) return null
const deficit = remaining - cumulative
const weeksNeeded = Math.ceil(deficit / steadyWeekly)
return lastEventDate.add(weeksNeeded * 7, 'day').toDate()
}

View file

@ -69,3 +69,15 @@ export function hasAdminPage(slug) {
const cleaned = slug.replace(/^\/+/, '').split('/')[0]
return getAdminComponent(cleaned) !== null
}
/**
* chunk prefetch: 렌더 트리거 없이 동적 import 시작
* 메뉴 카드 hover 호출해 네비게이션 직후 Suspense 깜빡임을 제거.
*/
export function prefetchUserComponent(slug) {
if (!slug) return
const cleaned = slug.replace(/^\/+/, '').split('/')[0]
const pascal = slugToPascal(cleaned)
const loader = pages[`./${cleaned}/pc/${pascal}.jsx`]
if (loader) loader()
}

View file

@ -105,6 +105,9 @@
--warning-text-bright: #fcd34d;
--warning-text-dim: rgba(252, 211, 77, 0.4);
--genesis-date: #fcd34d;
--destiny-date: #38bdf8;
--progress-track: #0f172a;
--progress-emerald: #10b981;
--progress-amber: #f59e0b;
@ -141,10 +144,10 @@
--btn-danger-bg-hover: #ef4444;
--btn-danger-shadow: 0 4px 14px rgba(239, 68, 68, 0.2);
--liberation-primary: #a78bfa;
--liberation-primary-bar: rgba(167, 139, 250, 0.5);
--liberation-secondary: #fda4af;
--liberation-secondary-bar: rgba(253, 164, 175, 0.5);
--liberation-primary: rgba(255, 255, 255, 0.55);
--liberation-primary-bar: rgba(255, 255, 255, 0.25);
--liberation-secondary: rgb(255, 255, 255);
--liberation-secondary-bar: rgba(255, 255, 255, 0.5);
--symbol-arcane-text: #c4b5fd;
--symbol-arcane-bg: rgba(139, 92, 246, 0.15);
@ -256,6 +259,9 @@
--warning-text-bright: #ea580c;
--warning-text-dim: rgba(234, 88, 12, 0.4);
--genesis-date: #f59e0b;
--destiny-date: #0ea5e9;
--progress-track: #e5e7eb;
--progress-emerald: #10b981;
--progress-amber: #f59e0b;
@ -292,10 +298,10 @@
--btn-danger-bg-hover: #b91c1c;
--btn-danger-shadow: 0 4px 14px rgba(220, 38, 38, 0.25);
--liberation-primary: #7c3aed;
--liberation-primary-bar: rgba(124, 58, 237, 0.5);
--liberation-secondary: #e11d48;
--liberation-secondary-bar: rgba(225, 29, 72, 0.5);
--liberation-primary: rgba(0, 0, 0, 0.55);
--liberation-primary-bar: rgba(0, 0, 0, 0.25);
--liberation-secondary: rgb(0, 0, 0);
--liberation-secondary-bar: rgba(0, 0, 0, 0.5);
--symbol-arcane-text: #6d28d9;
--symbol-arcane-bg: rgba(139, 92, 246, 0.12);
@ -313,9 +319,6 @@ html, body, #root {
background-color: var(--bg-from);
background-image: linear-gradient(to bottom right, var(--bg-from), var(--bg-via), var(--bg-to));
background-attachment: fixed;
transition:
background-color 500ms cubic-bezier(0.4, 0, 0.2, 1),
background-image 500ms cubic-bezier(0.4, 0, 0.2, 1);
}
html {
overscroll-behavior-y: contain;

View file

@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { api } from '../../api/client'
import NoticeWidget from '../../components/pc/NoticeWidget'
import SundayMapleBanner from '../../components/pc/SundayMapleBanner'
import { prefetchUserComponent } from '../../features/registry'
export default function Home() {
const { data: menus = [], isLoading: loading } = useQuery({
@ -64,6 +65,8 @@ export default function Home() {
<Link
key={menu.id}
to={menu.url}
onMouseEnter={() => prefetchUserComponent(menu.url)}
onFocus={() => prefetchUserComponent(menu.url)}
className="relative rounded-2xl border p-6 transition-transform duration-300 hover:scale-[1.02] border-[var(--card-border)]"
style={{
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',