Compare commits
23 commits
dc48f57501
...
281332ad14
| Author | SHA1 | Date | |
|---|---|---|---|
| 281332ad14 | |||
| f845e74844 | |||
| 98b27a5fae | |||
| f63c1e06c5 | |||
| 669b358460 | |||
| 6e2159cf67 | |||
| 670d8abc12 | |||
| f5c5c6927e | |||
| 48f43ecc0b | |||
| 1344a2f7a9 | |||
| d1764dea94 | |||
| 7ebfe4a449 | |||
| ea9a6461f2 | |||
| 66cafdb540 | |||
| c072cccf44 | |||
| b3907ec48f | |||
| 1ee3f19f4f | |||
| ee30c87518 | |||
| 99500d91af | |||
| d506c022ca | |||
| 0448b0bfc8 | |||
| 29fcb39eb3 | |||
| 2911dfe3a8 |
28 changed files with 894 additions and 430 deletions
3
backend/.dockerignore
Normal file
3
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
.git
|
||||
*.log
|
||||
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal 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
11
backend/constants.js
Normal 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;
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 - 원본 이미지 버퍼
|
||||
|
|
|
|||
|
|
@ -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
4
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal 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"]
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
217
frontend/src/features/liberation/pc/Destiny.jsx
Normal file
217
frontend/src/features/liberation/pc/Destiny.jsx
Normal 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
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
243
frontend/src/features/liberation/pc/Genesis.jsx
Normal file
243
frontend/src/features/liberation/pc/Genesis.jsx
Normal 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
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue