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 { DataTypes } from 'sequelize';
|
||||||
import { sequelize } from '../../lib/db.js';
|
import { sequelize } from '../../lib/db.js';
|
||||||
|
import { DIFFICULTIES } from '../../constants.js';
|
||||||
|
|
||||||
export const BossCrystalBossDifficulty = sequelize.define('BossCrystalBossDifficulty', {
|
export const BossCrystalBossDifficulty = sequelize.define('BossCrystalBossDifficulty', {
|
||||||
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
boss_id: { type: DataTypes.INTEGER, allowNull: false },
|
boss_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
difficulty: {
|
difficulty: {
|
||||||
type: DataTypes.ENUM('easy', 'normal', 'hard', 'chaos', 'extreme'),
|
type: DataTypes.ENUM(...DIFFICULTIES),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
crystal_price: { type: DataTypes.BIGINT, allowNull: false },
|
crystal_price: { type: DataTypes.BIGINT, allowNull: false },
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { Image, Menu } from '../models/index.js';
|
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 { getPublicUrl } from '../lib/s3.js';
|
||||||
import { sequelize } from '../lib/db.js';
|
import { sequelize } from '../lib/db.js';
|
||||||
import bossCrystalRouter from './admin/boss-crystal.js';
|
import bossCrystalRouter from './admin/boss-crystal.js';
|
||||||
import symbolRouter from './admin/symbol.js';
|
import symbolRouter from './admin/symbol.js';
|
||||||
|
import { UPLOAD_FILE_SIZE_LIMIT } from '../constants.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
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 {
|
try {
|
||||||
const images = await Image.findAll({ where: { id: ids } });
|
const images = await Image.findAll({ where: { id: ids } });
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(images.map((img) => safeDelete(img.path)));
|
||||||
images.map((img) =>
|
|
||||||
deleteFromS3(img.path).catch((err) =>
|
|
||||||
console.warn(`S3 삭제 실패 (${img.path}):`, err.message)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await Image.destroy({ where: { id: ids } });
|
await Image.destroy({ where: { id: ids } });
|
||||||
|
|
||||||
res.json({ success: true, deleted: images.length });
|
res.json({ success: true, deleted: images.length });
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { BossCrystalBoss, BossCrystalBossDifficulty } from '../../models/index.js';
|
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 { getPublicUrl } from '../../lib/s3.js';
|
||||||
import { sequelize } from '../../lib/db.js';
|
import { sequelize } from '../../lib/db.js';
|
||||||
|
import { UPLOAD_FILE_SIZE_LIMIT, PARTY_SIZE, DIFFICULTIES } from '../../constants.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: UPLOAD_FILE_SIZE_LIMIT },
|
||||||
});
|
});
|
||||||
|
|
||||||
function serialize(boss) {
|
function serialize(boss) {
|
||||||
|
|
@ -39,9 +43,8 @@ function parseDifficulties(raw) {
|
||||||
if (!Array.isArray(arr) || arr.length === 0) {
|
if (!Array.isArray(arr) || arr.length === 0) {
|
||||||
throw new Error('하나 이상의 난이도가 필요합니다');
|
throw new Error('하나 이상의 난이도가 필요합니다');
|
||||||
}
|
}
|
||||||
const valid = ['easy', 'normal', 'hard', 'chaos', 'extreme'];
|
|
||||||
return arr.map((d) => {
|
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);
|
const price = Number(d.crystal_price);
|
||||||
if (isNaN(price) || price <= 0) throw new Error(`잘못된 가격: ${d.difficulty}`);
|
if (isNaN(price) || price <= 0) throw new Error(`잘못된 가격: ${d.difficulty}`);
|
||||||
return { difficulty: d.difficulty, crystal_price: price };
|
return { difficulty: d.difficulty, crystal_price: price };
|
||||||
|
|
@ -50,7 +53,9 @@ function parseDifficulties(raw) {
|
||||||
|
|
||||||
function parseMaxParty(raw) {
|
function parseMaxParty(raw) {
|
||||||
const n = Number(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;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +104,7 @@ router.post('/bosses', upload.single('image'), async (req, res) => {
|
||||||
if (existing) return res.status(400).json({ error: '같은 이름의 보스가 이미 존재합니다' });
|
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;
|
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) {
|
if (req.file) {
|
||||||
const oldPath = boss.image_path;
|
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) {
|
if (oldPath && oldPath !== newImagePath) {
|
||||||
await deleteBossImage(oldPath);
|
await safeDelete(oldPath);
|
||||||
}
|
}
|
||||||
} else if (newName !== boss.name && boss.image_path) {
|
} else if (newName !== boss.name && boss.image_path) {
|
||||||
// 이름만 변경 - 기존 이미지를 새 경로로 복사하는 대신 이름 기반 경로 업데이트
|
// 이름 변경 시 path만 갱신 (실제 파일은 다음 이미지 업로드 때 새 경로로 저장됨)
|
||||||
// 간단하게 처리: 기존 키를 새 키로 교체할 수 없으니, 이미지가 없으면 그대로, 있으면 path만 갱신
|
newImagePath = bossImagePath(newName);
|
||||||
newImagePath = `crystal/boss/${newName}.webp`;
|
|
||||||
// 실제로 이름이 바뀌면 이미지를 다시 올려달라고 하는 게 안전. 현재는 path만 업데이트하고 추후 다음 업로드 시 새 경로로 저장됨
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await sequelize.transaction(async (tx) => {
|
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) return res.status(404).json({ error: '보스를 찾을 수 없습니다' });
|
||||||
|
|
||||||
if (boss.image_path) {
|
if (boss.image_path) {
|
||||||
await deleteBossImage(boss.image_path);
|
await safeDelete(boss.image_path);
|
||||||
}
|
}
|
||||||
await boss.destroy();
|
await boss.destroy();
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { Symbol, SymbolLevel } from '../../models/index.js';
|
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 { getPublicUrl } from '../../lib/s3.js';
|
||||||
import { sequelize } from '../../lib/db.js';
|
import { sequelize } from '../../lib/db.js';
|
||||||
|
import { UPLOAD_FILE_SIZE_LIMIT, SYMBOL_MASTER_LEVEL } from '../../constants.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: UPLOAD_FILE_SIZE_LIMIT },
|
||||||
});
|
});
|
||||||
|
|
||||||
const VALID_TYPES = ['아케인', '어센틱', '그랜드 어센틱'];
|
const VALID_TYPES = ['아케인', '어센틱', '그랜드 어센틱'];
|
||||||
|
|
@ -70,7 +71,9 @@ function validateBasic({ type, region, max_level }) {
|
||||||
const r = String(region || '').trim();
|
const r = String(region || '').trim();
|
||||||
if (!r) throw new Error('지역 이름을 입력해주세요');
|
if (!r) throw new Error('지역 이름을 입력해주세요');
|
||||||
const ml = Number(max_level);
|
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 };
|
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);
|
imageKey = imagePath(basic.type, basic.region);
|
||||||
await convertAndUploadTo(req.file.buffer, imageKey);
|
await convertAndUploadTo(req.file.buffer, imageKey);
|
||||||
if (row.image && row.image !== 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) {
|
} else if (basic.type !== row.type || basic.region !== row.region) {
|
||||||
// 이름/종류 변경 시 새 경로로 rename 대체 불가 → 기존 키 유지
|
// 이름/종류 변경 시 새 경로로 rename 대체 불가 → 기존 키 유지
|
||||||
|
|
@ -208,7 +211,7 @@ router.delete('/symbols/:id', async (req, res) => {
|
||||||
if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' });
|
if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' });
|
||||||
const key = row.image;
|
const key = row.image;
|
||||||
await row.destroy();
|
await row.destroy();
|
||||||
if (key) { try { await deleteFromS3(key); } catch { /* ignore */ } }
|
if (key) await safeDelete(key);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('심볼 삭제 오류:', err.message);
|
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);
|
await deleteObject(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 삭제 실패해도 흐름을 끊지 않는 버전 (이전 이미지 정리 등에 사용)
|
||||||
|
export async function safeDelete(path) {
|
||||||
|
if (!path) return;
|
||||||
|
try {
|
||||||
|
await deleteObject(path);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`S3 삭제 실패 (${path}):`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지정한 경로로 webp 변환 후 업로드 (덮어쓰기)
|
* 지정한 경로로 webp 변환 후 업로드 (덮어쓰기)
|
||||||
* @param {Buffer} buffer - 원본 이미지 버퍼
|
* @param {Buffer} buffer - 원본 이미지 버퍼
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,26 @@
|
||||||
services:
|
services:
|
||||||
frontend:
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
container_name: maplestory-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:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
networks:
|
networks:
|
||||||
- caddy
|
- caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
|
build: ./backend
|
||||||
container_name: maplestory-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
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
networks:
|
networks:
|
||||||
- caddy
|
- caddy
|
||||||
- db
|
- db
|
||||||
- app
|
- app
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
|
||||||
frontend_modules:
|
|
||||||
backend_modules:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
caddy:
|
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)' }}
|
style={{ color: 'var(--text-strong)' }}
|
||||||
>
|
>
|
||||||
<header
|
<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={{
|
style={{
|
||||||
borderColor: 'var(--header-border)',
|
borderColor: 'var(--header-border)',
|
||||||
|
background: 'var(--bg-from)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-4">
|
<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"
|
className="shrink-0 w-11 h-11 rounded-lg overflow-hidden"
|
||||||
style={{ background: 'var(--surface-nested)' }}
|
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>
|
</div>
|
||||||
<span className="text-base font-medium leading-tight whitespace-nowrap overflow-hidden text-ellipsis">{boss.name}</span>
|
<span className="text-base font-medium leading-tight whitespace-nowrap overflow-hidden text-ellipsis">{boss.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ function CharacterContent({ char, selections, bosses }) {
|
||||||
className="w-full h-full object-contain scale-[3] origin-center select-none"
|
className="w-full h-full object-contain scale-[3] origin-center select-none"
|
||||||
style={{ imageRendering: 'pixelated' }}
|
style={{ imageRendering: 'pixelated' }}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-4xl" style={{ color: 'var(--text-dim)' }}>?</span>
|
<span className="text-4xl" style={{ color: 'var(--text-dim)' }}>?</span>
|
||||||
|
|
@ -79,7 +81,7 @@ function CharacterContent({ char, selections, bosses }) {
|
||||||
borderColor: 'var(--panel-border)',
|
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>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div
|
<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_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'
|
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 BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/crystal/boss'
|
||||||
export const DIFFICULTY_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/crystal/difficulty'
|
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) {
|
export function calcPoints(basePoints, partySize) {
|
||||||
return Math.floor(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 { useQuery } from '@tanstack/react-query'
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import { api } from '../../../api/client'
|
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 { useLayout } from '../../../components/pc/Layout'
|
||||||
|
import { useLiberationStore } from '../store'
|
||||||
|
import Genesis from './Genesis'
|
||||||
|
import Destiny from './Destiny'
|
||||||
|
|
||||||
export default function Liberation() {
|
export default function Liberation() {
|
||||||
const { setFullscreen } = useLayout()
|
const { setFullscreen } = useLayout()
|
||||||
|
|
@ -32,7 +13,8 @@ export default function Liberation() {
|
||||||
return () => setFullscreen(false)
|
return () => setFullscreen(false)
|
||||||
}, [setFullscreen])
|
}, [setFullscreen])
|
||||||
|
|
||||||
const [liberationType, setLiberationType] = useState('genesis') // 'genesis' | 'destiny'
|
const liberationType = useLiberationStore((s) => s.liberationType)
|
||||||
|
const setLiberationType = useLiberationStore((s) => s.setLiberationType)
|
||||||
|
|
||||||
const genesisImg = useQuery({
|
const genesisImg = useQuery({
|
||||||
queryKey: ['image', '제네시스 스태프'],
|
queryKey: ['image', '제네시스 스태프'],
|
||||||
|
|
@ -45,72 +27,6 @@ export default function Liberation() {
|
||||||
staleTime: Infinity,
|
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 (
|
return (
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
{/* 해방 종류 탭 */}
|
{/* 해방 종류 탭 */}
|
||||||
|
|
@ -144,156 +60,7 @@ export default function Liberation() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{liberationType === 'destiny' ? (
|
{liberationType === 'genesis' ? <Genesis /> : <Destiny />}
|
||||||
<div
|
|
||||||
className="max-w-3xl mx-auto rounded-2xl border p-16 text-center space-y-3 flex flex-col items-center justify-center"
|
|
||||||
style={{
|
|
||||||
minHeight: 'calc(100vh - 220px)',
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
boxShadow: 'var(--panel-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-2xl font-bold" style={{ color: 'var(--text-emphasis)' }}>구현 예정</div>
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-dim)' }}>데스티니 해방 계산기는 준비 중입니다.</div>
|
|
||||||
</div>
|
|
||||||
) : (<>
|
|
||||||
{/* 계산 모드 탭 */}
|
|
||||||
<div
|
|
||||||
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
|
|
||||||
style={{
|
|
||||||
background: 'var(--surface-3)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ key: 'simple', label: '단순 계산' },
|
|
||||||
{ key: 'weekly', label: '주차별 계산' },
|
|
||||||
].map((t) => {
|
|
||||||
const active = calcMode === t.key
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={t.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCalcMode(t.key)}
|
|
||||||
className="flex-1 h-10 rounded-lg text-sm font-semibold"
|
|
||||||
style={active ? {
|
|
||||||
background: 'var(--selected-bg)',
|
|
||||||
color: 'var(--accent-bright)',
|
|
||||||
} : {
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
startChapter={state.startChapter}
|
|
||||||
currentPoints={state.currentPoints}
|
|
||||||
completionDate={isDone ? formatDate(completionDate) : null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 현재 진행 상태 입력 */}
|
|
||||||
<div
|
|
||||||
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
boxShadow: 'var(--panel-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 grid-cols-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
|
|
||||||
<DatePicker
|
|
||||||
value={formatDate(state.startDate)}
|
|
||||||
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
|
|
||||||
<QuestSelector
|
|
||||||
value={state.startChapter}
|
|
||||||
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 흔적</label>
|
|
||||||
<div
|
|
||||||
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
|
||||||
style={{
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PointsInput
|
|
||||||
value={state.currentPoints}
|
|
||||||
max={3000}
|
|
||||||
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
|
|
||||||
className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
|
|
||||||
style={{ color: 'var(--text-strong)' }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="flex items-center px-3 text-base border-l select-none tabular-nums"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
color: 'var(--text-dim)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
/ {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<WeeklyDefault
|
|
||||||
weekly={state.weekly}
|
|
||||||
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
|
||||||
totalWeekly={headerWeekly}
|
|
||||||
totalMonthly={headerMonthly}
|
|
||||||
remaining={remaining}
|
|
||||||
mode={calcMode}
|
|
||||||
startDate={state.startDate}
|
|
||||||
weeks={state.schedulerWeeks}
|
|
||||||
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setResetOpen(true)}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--icon-danger-border)',
|
|
||||||
background: 'var(--icon-danger-bg)',
|
|
||||||
color: 'var(--danger-text)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
||||||
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
전체 초기화
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>)}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={resetOpen}
|
|
||||||
onClose={() => setResetOpen(false)}
|
|
||||||
onConfirm={doReset}
|
|
||||||
title="전체 초기화"
|
|
||||||
description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
|
|
||||||
confirmText="초기화"
|
|
||||||
destructive
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../../data'
|
|
||||||
|
|
||||||
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
||||||
function formatKoreanDate(s) {
|
function formatKoreanDate(s) {
|
||||||
const [y, m, d] = s.split('-')
|
const [y, m, d] = s.split('-')
|
||||||
|
|
@ -7,8 +5,15 @@ function formatKoreanDate(s) {
|
||||||
return `${y}년 ${m}월 ${d}일 (${dow})`
|
return `${y}년 ${m}월 ${d}일 (${dow})`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar({ startChapter, currentPoints, completionDate }) {
|
export default function ProgressBar({
|
||||||
const chapterStates = GENESIS_CHAPTERS.map((c) => {
|
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) return { chapter: c, status: 'done', current: c.required }
|
||||||
if (c.idx === startChapter) {
|
if (c.idx === startChapter) {
|
||||||
const filled = Math.min(currentPoints, c.required)
|
const filled = Math.min(currentPoints, c.required)
|
||||||
|
|
@ -41,7 +46,7 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
|
||||||
status === 'pending' ? 'opacity-50' : ''
|
status === 'pending' ? 'opacity-50' : ''
|
||||||
}`}>
|
}`}>
|
||||||
<img
|
<img
|
||||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.webp`}
|
src={`${imageBase}/${chapter.boss}.webp`}
|
||||||
alt={chapter.boss}
|
alt={chapter.boss}
|
||||||
className={`block w-full h-full object-cover ${status === 'pending' ? 'grayscale' : ''}`}
|
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 style={{ color: 'var(--text-dim)' }}>·</span>
|
||||||
<span
|
<span
|
||||||
className="text-xl font-bold tabular-nums"
|
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>}
|
{completionDate ? formatKoreanDate(completionDate) : <span className="font-normal" style={{ color: 'var(--text-dim)' }}>미정</span>}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
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 [open, setOpen] = useState(false)
|
||||||
const ref = useRef(null)
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ export default function QuestSelector({ value, onChange }) {
|
||||||
return () => document.removeEventListener('mousedown', handler)
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const selected = GENESIS_CHAPTERS[value]
|
const selected = chapters[value]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
|
|
@ -38,7 +38,7 @@ export default function QuestSelector({ value, onChange }) {
|
||||||
style={{ background: 'var(--surface-nested)' }}
|
style={{ background: 'var(--surface-nested)' }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${QUEST_BOSS_IMAGE_BASE}/${selected.boss}.webp`}
|
src={`${imageBase}/${selected.boss}.webp`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
@ -69,7 +69,7 @@ export default function QuestSelector({ value, onChange }) {
|
||||||
boxShadow: 'var(--popup-shadow)',
|
boxShadow: 'var(--popup-shadow)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{GENESIS_CHAPTERS.map((chapter) => {
|
{chapters.map((chapter) => {
|
||||||
const isSelected = chapter.idx === value
|
const isSelected = chapter.idx === value
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -86,7 +86,7 @@ export default function QuestSelector({ value, onChange }) {
|
||||||
style={{ background: 'var(--surface-nested)' }}
|
style={{ background: 'var(--surface-nested)' }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.webp`}
|
src={`${imageBase}/${chapter.boss}.webp`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import Select from '../../../../components/common/Select'
|
import Select from '../../../../components/common/Select'
|
||||||
import Tooltip from '../../../../components/common/Tooltip'
|
import Tooltip from '../../../../components/common/Tooltip'
|
||||||
import WeeklyScheduler from './WeeklyScheduler'
|
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 PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
||||||
const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 }
|
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 disabled = sel.difficulty === 'none'
|
||||||
const difficultyOptions = [NONE_DIFFICULTY, ...boss.difficulties]
|
const difficultyOptions = [NONE_DIFFICULTY, ...boss.difficulties]
|
||||||
.map((d) => ({ value: d.key, label: diffLabel(d, sel.party) }))
|
.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 (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-lg px-3 h-16">
|
<div className="flex items-center gap-3 rounded-lg px-3 h-16">
|
||||||
<Tooltip text={boss.name}>
|
<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>
|
</Tooltip>
|
||||||
<span className="text-base font-semibold flex-1 truncate">
|
<span className="text-base font-semibold flex-1 truncate">
|
||||||
{boss.name}
|
{boss.name}
|
||||||
|
|
@ -61,6 +61,7 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onChange({ done: !sel.done })}
|
onClick={() => onChange({ done: !sel.done })}
|
||||||
|
title="이번 주 해당 난이도를 이미 클리어했는지 여부"
|
||||||
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border"
|
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border"
|
||||||
style={disabled ? {
|
style={disabled ? {
|
||||||
borderColor: 'var(--panel-border)',
|
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) => {
|
const updateBoss = (key, patch) => {
|
||||||
onChange({ ...weekly, bosses: { ...weekly.bosses, [key]: { ...weekly.bosses[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="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">
|
<div className="text-sm tabular-nums">
|
||||||
{mode === 'weekly' ? (
|
{mode === 'weekly' ? (
|
||||||
<>
|
<>
|
||||||
<span className="font-semibold" style={{ color: 'var(--accent-bright)' }}>{totalWeekly}</span>
|
<span className="font-semibold" style={{ color: 'var(--accent-bright)' }}>{totalWeekly}</span>
|
||||||
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>+</span>
|
{monthlyBosses.length > 0 && (
|
||||||
<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(--warning-text-bright)' }}>{totalMonthly}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>/</span>
|
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>/</span>
|
||||||
<span className="font-semibold" style={{ color: 'var(--text-emphasis)' }}>{(remaining ?? 0).toLocaleString()}</span>
|
<span className="font-semibold" style={{ color: 'var(--text-emphasis)' }}>{(remaining ?? 0).toLocaleString()}</span>
|
||||||
</>
|
</>
|
||||||
|
|
@ -115,9 +136,9 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'simple' ? (
|
{mode === 'simple' || !hasScheduler ? (
|
||||||
<div>
|
<div>
|
||||||
{WEEKLY_BOSSES.map((boss, i) => (
|
{bosses.map((boss, i) => (
|
||||||
<div
|
<div
|
||||||
key={boss.key}
|
key={boss.key}
|
||||||
className={i > 0 ? 'border-t' : ''}
|
className={i > 0 ? 'border-t' : ''}
|
||||||
|
|
@ -127,10 +148,11 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
||||||
boss={boss}
|
boss={boss}
|
||||||
sel={weekly.bosses[boss.key]}
|
sel={weekly.bosses[boss.key]}
|
||||||
onChange={(patch) => updateBoss(boss.key, patch)}
|
onChange={(patch) => updateBoss(boss.key, patch)}
|
||||||
|
imageBase={imageBase}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{MONTHLY_BOSSES.map((boss) => (
|
{monthlyBosses.map((boss) => (
|
||||||
<div
|
<div
|
||||||
key={boss.key}
|
key={boss.key}
|
||||||
className="border-t"
|
className="border-t"
|
||||||
|
|
@ -140,6 +162,7 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
||||||
boss={boss}
|
boss={boss}
|
||||||
sel={weekly.blackMage}
|
sel={weekly.blackMage}
|
||||||
onChange={updateBlackMage}
|
onChange={updateBlackMage}
|
||||||
|
imageBase={imageBase}
|
||||||
monthly
|
monthly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,6 +170,10 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<WeeklyScheduler
|
<WeeklyScheduler
|
||||||
|
bosses={bosses}
|
||||||
|
monthlyBoss={monthlyBosses[0] ?? null}
|
||||||
|
imageBase={imageBase}
|
||||||
|
makeEmptyConfig={makeEmptyConfig}
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
weeks={weeks}
|
weeks={weeks}
|
||||||
onChangeWeeks={onChangeWeeks}
|
onChangeWeeks={onChangeWeeks}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
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 { bossEarn, calcWeekPoints as calcWeeklySum, getSchedulerWeekRange as getWeekRange } from '../../utils'
|
||||||
import { BossRow } from './WeeklyDefault'
|
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)' },
|
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 badge = DIFF_BADGE[difficulty]
|
||||||
const enabled = difficulty && difficulty !== 'none'
|
const enabled = difficulty && difficulty !== 'none'
|
||||||
return (
|
return (
|
||||||
|
|
@ -32,7 +30,7 @@ function BossAvatar({ boss, difficulty, size = 40 }) {
|
||||||
borderColor: 'var(--panel-border)',
|
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>
|
||||||
<div
|
<div
|
||||||
className="text-[10px] font-bold leading-none rounded flex items-center justify-center border"
|
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) => {
|
const updateBoss = (key, patch) => {
|
||||||
onChange({ ...config, bosses: { ...config.bosses, [key]: { ...config.bosses[key], ...patch } } })
|
onChange({ ...config, bosses: { ...config.bosses, [key]: { ...config.bosses[key], ...patch } } })
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +59,7 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{WEEKLY_BOSSES.map((boss, i) => (
|
{bosses.map((boss, i) => (
|
||||||
<div
|
<div
|
||||||
key={boss.key}
|
key={boss.key}
|
||||||
className={i > 0 ? 'border-t' : ''}
|
className={i > 0 ? 'border-t' : ''}
|
||||||
|
|
@ -71,23 +69,27 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
|
||||||
boss={boss}
|
boss={boss}
|
||||||
sel={config.bosses[boss.key]}
|
sel={config.bosses[boss.key]}
|
||||||
onChange={(patch) => updateBoss(boss.key, patch)}
|
onChange={(patch) => updateBoss(boss.key, patch)}
|
||||||
|
imageBase={imageBase}
|
||||||
showDone={isCurrent}
|
showDone={isCurrent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div
|
{monthlyBoss && (
|
||||||
className={`border-t ${blackmageLocked ? 'opacity-40 pointer-events-none' : ''}`}
|
<div
|
||||||
style={{ borderColor: 'var(--row-divider)' }}
|
className={`border-t ${blackmageLocked ? 'opacity-40 pointer-events-none' : ''}`}
|
||||||
>
|
style={{ borderColor: 'var(--row-divider)' }}
|
||||||
<BossRow
|
>
|
||||||
boss={MONTHLY_BOSSES[0]}
|
<BossRow
|
||||||
sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage}
|
boss={monthlyBoss}
|
||||||
onChange={updateBlackMage}
|
sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage}
|
||||||
monthly
|
onChange={updateBlackMage}
|
||||||
showDone={isCurrent}
|
imageBase={imageBase}
|
||||||
/>
|
monthly
|
||||||
</div>
|
showDone={isCurrent}
|
||||||
{blackmageLocked && (
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{monthlyBoss && blackmageLocked && (
|
||||||
<div
|
<div
|
||||||
className="text-[11px] px-3 py-2"
|
className="text-[11px] px-3 py-2"
|
||||||
style={{ color: 'var(--warning-text)' }}
|
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
|
const weeks = weeksProp && weeksProp.length > 0
|
||||||
? weeksProp
|
? weeksProp
|
||||||
: [{ id: 1, config: makeEmptyWeekly() }]
|
: [{ id: 1, config: makeEmptyConfig() }]
|
||||||
const setWeeks = (updater) => {
|
const setWeeks = (updater) => {
|
||||||
const next = typeof updater === 'function' ? updater(weeks) : updater
|
const next = typeof updater === 'function' ? updater(weeks) : updater
|
||||||
onChangeWeeks?.(next)
|
onChangeWeeks?.(next)
|
||||||
|
|
@ -114,13 +124,13 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
||||||
const id = nextId()
|
const id = nextId()
|
||||||
setWeeks((prev) => {
|
setWeeks((prev) => {
|
||||||
const last = prev[prev.length - 1]
|
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 상태는 복사하지 않음
|
// done 상태는 복사하지 않음
|
||||||
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
|
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
|
||||||
if (base.blackMage) base.blackMage.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 newIdx = prev.length + 1
|
||||||
const newMonth = getWeekRange(startDate, newIdx).start.format('YYYY-MM')
|
const newMonth = getWeekRange(startDate, newIdx).start.format('YYYY-MM')
|
||||||
const existsInSameMonth = prev.some((p, i) => {
|
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)))
|
setWeeks((prev) => prev.map((w) => (w.id === id ? { ...w, config } : w)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검은 마법사 월별 슬롯 배정: 각 주차가 겹치는 달 중 하나를 선점
|
// 월간 보스 슬롯 배정: 각 주차가 겹치는 달 중 하나를 선점
|
||||||
const monthlyLocks = (() => {
|
const monthlyLocks = (() => {
|
||||||
if (!startDate) return {}
|
if (!monthlyBoss || !startDate) return {}
|
||||||
const claimed = {} // month -> weekNum (1-based)
|
const claimed = {} // month -> weekNum (1-based)
|
||||||
weeks.forEach((w, idx) => {
|
weeks.forEach((w, idx) => {
|
||||||
const diff = w.config.blackMage?.difficulty
|
const diff = w.config.blackMage?.difficulty
|
||||||
|
|
@ -166,9 +176,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
||||||
weeks.forEach((w, idx) => {
|
weeks.forEach((w, idx) => {
|
||||||
const r = getWeekRange(startDate, idx + 1)
|
const r = getWeekRange(startDate, idx + 1)
|
||||||
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
|
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
|
||||||
// 본인이 한 달이라도 차지했으면 잠그지 않음
|
|
||||||
if (months.some((m) => claimed[m] === idx + 1)) return
|
if (months.some((m) => claimed[m] === idx + 1)) return
|
||||||
// 겹치는 달이 모두 다른 주차에 점유되었으면 잠금
|
|
||||||
if (months.every((m) => m in claimed)) {
|
if (months.every((m) => m in claimed)) {
|
||||||
locks[idx] = claimed[months[0]] ?? claimed[months[1]]
|
locks[idx] = claimed[months[0]] ?? claimed[months[1]]
|
||||||
}
|
}
|
||||||
|
|
@ -181,8 +189,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
||||||
{weeks.map((w, idx) => {
|
{weeks.map((w, idx) => {
|
||||||
const n = idx + 1
|
const n = idx + 1
|
||||||
const isOpen = expanded === w.id
|
const isOpen = expanded === w.id
|
||||||
const isCurrent = idx === 0 // 임시: 첫 번째가 현재 주차 (실제 연결 시 날짜 기반)
|
const isCurrent = idx === 0
|
||||||
// 검은마법사 잠금 판정은 아래 사전 계산된 monthlyLocks 사용
|
|
||||||
const monthlyLockedByWeek = monthlyLocks[idx] ?? null
|
const monthlyLockedByWeek = monthlyLocks[idx] ?? null
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -218,15 +225,24 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 flex items-center gap-2">
|
<div className="flex-1 flex items-center gap-2">
|
||||||
{WEEKLY_BOSSES.map((b) => (
|
{bosses.map((b) => (
|
||||||
<BossAvatar key={b.key} boss={b} difficulty={w.config.bosses[b.key]?.difficulty} size={40} />
|
<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>
|
</div>
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const weeklySum = calcWeeklySum(w.config)
|
const weeklySum = calcWeeklySum(w.config, bosses)
|
||||||
const monthlySum = monthlyLockedByWeek != null ? 0 : bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
|
const monthlySum = !monthlyBoss || monthlyLockedByWeek != null
|
||||||
|
? 0
|
||||||
|
: bossEarn(monthlyBoss, w.config.blackMage)
|
||||||
return (
|
return (
|
||||||
<div className="text-right shrink-0 pr-1 tabular-nums leading-tight">
|
<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>
|
<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)}
|
onChange={(c) => updateWeek(w.id, c)}
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
monthlyLockedByWeek={monthlyLockedByWeek}
|
monthlyLockedByWeek={monthlyLockedByWeek}
|
||||||
|
bosses={bosses}
|
||||||
|
monthlyBoss={monthlyBoss}
|
||||||
|
imageBase={imageBase}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { WEEKLY_BOSSES, MONTHLY_BOSSES, todayKST } from './data'
|
import { WEEKLY_BOSSES, MONTHLY_BOSSES, DESTINY_BOSSES, todayKST } from './data'
|
||||||
|
|
||||||
function makeEmptyWeekly() {
|
function makeEmptyWeekly() {
|
||||||
const bosses = {}
|
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() {
|
function makeInitialSlot() {
|
||||||
return {
|
return {
|
||||||
startChapter: 0,
|
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'
|
* calcMode: 'simple' | 'weekly' (제네시스/데스티니가 공유)
|
||||||
* simple / weekly: 각 모드 독립 슬롯
|
* simple / weekly: 제네시스 모드별 독립 슬롯
|
||||||
|
* destinySimple / destinyWeekly: 데스티니 모드별 독립 슬롯
|
||||||
*/
|
*/
|
||||||
export const useLiberationStore = create(persist(
|
export const useLiberationStore = create(persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
calcMode: 'simple',
|
liberationType: 'genesis', // 'genesis' | 'destiny'
|
||||||
|
genesisCalcMode: 'simple',
|
||||||
|
destinyCalcMode: 'simple',
|
||||||
simple: makeInitialSlot(),
|
simple: makeInitialSlot(),
|
||||||
weekly: 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) => ({
|
updateSlot: (patch) => set((s) => ({
|
||||||
[s.calcMode]: typeof patch === 'function'
|
[s.genesisCalcMode]: typeof patch === 'function'
|
||||||
? patch(s[s.calcMode])
|
? patch(s[s.genesisCalcMode])
|
||||||
: { ...s[s.calcMode], ...patch },
|
: { ...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)
|
return calcPoints(d.points, sel.party)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcWeekPoints(weekData) {
|
export function calcWeekPoints(weekData, bosses = WEEKLY_BOSSES) {
|
||||||
let points = 0
|
let points = 0
|
||||||
WEEKLY_BOSSES.forEach((b) => {
|
bosses.forEach((b) => {
|
||||||
points += bossEarn(b, weekData.bosses[b.key])
|
points += bossEarn(b, weekData.bosses[b.key])
|
||||||
})
|
})
|
||||||
return points
|
return points
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcDoneEarn(weekData) {
|
export function calcDoneEarn(weekData, bosses = WEEKLY_BOSSES) {
|
||||||
let points = 0
|
let points = 0
|
||||||
WEEKLY_BOSSES.forEach((b) => {
|
bosses.forEach((b) => {
|
||||||
const sel = weekData.bosses[b.key]
|
const sel = weekData.bosses[b.key]
|
||||||
if (sel?.done) points += bossEarn(b, sel)
|
if (sel?.done) points += bossEarn(b, sel)
|
||||||
})
|
})
|
||||||
|
|
@ -64,6 +64,9 @@ export function getSchedulerWeekRange(startDateStr, weekIdx) {
|
||||||
export function computeCompletionDate({
|
export function computeCompletionDate({
|
||||||
calcMode, state, alreadyDone, remaining,
|
calcMode, state, alreadyDone, remaining,
|
||||||
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
|
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
|
||||||
|
bosses = WEEKLY_BOSSES,
|
||||||
|
monthlyBoss = MONTHLY_BOSSES[0],
|
||||||
|
makeEmptyConfig = makeEmptyWeekly,
|
||||||
}) {
|
}) {
|
||||||
if (alreadyDone) return todayKST()
|
if (alreadyDone) return todayKST()
|
||||||
if (remaining <= 0) return dayjs(state.startDate).tz(KST).startOf('day').toDate()
|
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
|
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
|
||||||
|
|
||||||
// 1주차: 시작일 당일에 (주간 - done) 적립
|
// 1주차: 시작일 당일에 (주간 - done) 적립
|
||||||
const week1Cfg = sw[0]?.config || makeEmptyWeekly()
|
const week1Cfg = sw[0]?.config || makeEmptyConfig()
|
||||||
const w1Weekly = calcWeekPoints(week1Cfg)
|
const w1Weekly = calcWeekPoints(week1Cfg, bosses)
|
||||||
const w1Done = calcDoneEarn(week1Cfg)
|
const w1Done = calcDoneEarn(week1Cfg, bosses)
|
||||||
events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) })
|
events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) })
|
||||||
|
|
||||||
// 2주차 이후: 각 목요일에 해당 주차 설정의 주간 합 적립
|
// 2주차 이후: 각 목요일에 해당 주차 설정의 주간 합 적립
|
||||||
// 마지막 주차 이후로는 마지막 주차 설정 반복 적용
|
// 마지막 주차 이후로는 마지막 주차 설정 반복 적용
|
||||||
let nextThu = startKST.add(daysToNextThu, 'day')
|
let nextThu = startKST.add(daysToNextThu, 'day')
|
||||||
for (let i = 1; i < 520; i++) {
|
for (let i = 1; i < 520; i++) {
|
||||||
const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyWeekly()
|
const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyConfig()
|
||||||
events.push({ date: nextThu, amount: calcWeekPoints(cfg) })
|
events.push({ date: nextThu, amount: calcWeekPoints(cfg, bosses) })
|
||||||
nextThu = nextThu.add(1, 'week')
|
nextThu = nextThu.add(1, 'week')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검은 마법사: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립
|
// 월간 보스: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립
|
||||||
const claimed = {}
|
const claimed = {}
|
||||||
sw.forEach((w, i) => {
|
if (monthlyBoss) {
|
||||||
const diff = w.config.blackMage?.difficulty
|
sw.forEach((w, i) => {
|
||||||
if (!diff || diff === 'none') return
|
const diff = w.config.blackMage?.difficulty
|
||||||
const range = getSchedulerWeekRange(state.startDate, i + 1)
|
if (!diff || diff === 'none') return
|
||||||
const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')]
|
const range = getSchedulerWeekRange(state.startDate, i + 1)
|
||||||
for (const m of months) {
|
const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')]
|
||||||
if (!(m in claimed)) {
|
for (const m of months) {
|
||||||
claimed[m] = {
|
if (!(m in claimed)) {
|
||||||
weekIdx: i,
|
claimed[m] = {
|
||||||
earn: bossEarn(MONTHLY_BOSSES[0], w.config.blackMage),
|
weekIdx: i,
|
||||||
done: !!w.config.blackMage.done,
|
earn: bossEarn(monthlyBoss, w.config.blackMage),
|
||||||
|
done: !!w.config.blackMage.done,
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
Object.entries(claimed).forEach(([, info]) => {
|
||||||
Object.entries(claimed).forEach(([, info]) => {
|
if (info.done) return
|
||||||
if (info.done) return
|
const wIdx = info.weekIdx
|
||||||
const wIdx = info.weekIdx
|
const date = wIdx === 0
|
||||||
const date = wIdx === 0
|
? startKST
|
||||||
? startKST
|
: startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day')
|
||||||
: startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day')
|
events.push({ date, amount: info.earn })
|
||||||
events.push({ date, amount: info.earn })
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
// 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용
|
// 마지막 주차 이후로는 마지막 주차의 월간 설정을 매월 반복 적용
|
||||||
const lastCfg = sw[sw.length - 1]?.config
|
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) {
|
if (lastBmEarn > 0) {
|
||||||
const lastWeekStart = sw.length === 1
|
const lastWeekStart = sw.length === 1
|
||||||
? startKST
|
? startKST
|
||||||
|
|
@ -166,9 +171,20 @@ export function computeCompletionDate({
|
||||||
|
|
||||||
events.sort((a, b) => a.date.diff(b.date))
|
events.sort((a, b) => a.date.diff(b.date))
|
||||||
let cumulative = 0
|
let cumulative = 0
|
||||||
|
let lastEventDate = startKST
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
cumulative += e.amount
|
cumulative += e.amount
|
||||||
|
lastEventDate = e.date
|
||||||
if (cumulative >= remaining) return e.date.toDate()
|
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]
|
const cleaned = slug.replace(/^\/+/, '').split('/')[0]
|
||||||
return getAdminComponent(cleaned) !== null
|
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-bright: #fcd34d;
|
||||||
--warning-text-dim: rgba(252, 211, 77, 0.4);
|
--warning-text-dim: rgba(252, 211, 77, 0.4);
|
||||||
|
|
||||||
|
--genesis-date: #fcd34d;
|
||||||
|
--destiny-date: #38bdf8;
|
||||||
|
|
||||||
--progress-track: #0f172a;
|
--progress-track: #0f172a;
|
||||||
--progress-emerald: #10b981;
|
--progress-emerald: #10b981;
|
||||||
--progress-amber: #f59e0b;
|
--progress-amber: #f59e0b;
|
||||||
|
|
@ -141,10 +144,10 @@
|
||||||
--btn-danger-bg-hover: #ef4444;
|
--btn-danger-bg-hover: #ef4444;
|
||||||
--btn-danger-shadow: 0 4px 14px rgba(239, 68, 68, 0.2);
|
--btn-danger-shadow: 0 4px 14px rgba(239, 68, 68, 0.2);
|
||||||
|
|
||||||
--liberation-primary: #a78bfa;
|
--liberation-primary: rgba(255, 255, 255, 0.55);
|
||||||
--liberation-primary-bar: rgba(167, 139, 250, 0.5);
|
--liberation-primary-bar: rgba(255, 255, 255, 0.25);
|
||||||
--liberation-secondary: #fda4af;
|
--liberation-secondary: rgb(255, 255, 255);
|
||||||
--liberation-secondary-bar: rgba(253, 164, 175, 0.5);
|
--liberation-secondary-bar: rgba(255, 255, 255, 0.5);
|
||||||
|
|
||||||
--symbol-arcane-text: #c4b5fd;
|
--symbol-arcane-text: #c4b5fd;
|
||||||
--symbol-arcane-bg: rgba(139, 92, 246, 0.15);
|
--symbol-arcane-bg: rgba(139, 92, 246, 0.15);
|
||||||
|
|
@ -256,6 +259,9 @@
|
||||||
--warning-text-bright: #ea580c;
|
--warning-text-bright: #ea580c;
|
||||||
--warning-text-dim: rgba(234, 88, 12, 0.4);
|
--warning-text-dim: rgba(234, 88, 12, 0.4);
|
||||||
|
|
||||||
|
--genesis-date: #f59e0b;
|
||||||
|
--destiny-date: #0ea5e9;
|
||||||
|
|
||||||
--progress-track: #e5e7eb;
|
--progress-track: #e5e7eb;
|
||||||
--progress-emerald: #10b981;
|
--progress-emerald: #10b981;
|
||||||
--progress-amber: #f59e0b;
|
--progress-amber: #f59e0b;
|
||||||
|
|
@ -292,10 +298,10 @@
|
||||||
--btn-danger-bg-hover: #b91c1c;
|
--btn-danger-bg-hover: #b91c1c;
|
||||||
--btn-danger-shadow: 0 4px 14px rgba(220, 38, 38, 0.25);
|
--btn-danger-shadow: 0 4px 14px rgba(220, 38, 38, 0.25);
|
||||||
|
|
||||||
--liberation-primary: #7c3aed;
|
--liberation-primary: rgba(0, 0, 0, 0.55);
|
||||||
--liberation-primary-bar: rgba(124, 58, 237, 0.5);
|
--liberation-primary-bar: rgba(0, 0, 0, 0.25);
|
||||||
--liberation-secondary: #e11d48;
|
--liberation-secondary: rgb(0, 0, 0);
|
||||||
--liberation-secondary-bar: rgba(225, 29, 72, 0.5);
|
--liberation-secondary-bar: rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
--symbol-arcane-text: #6d28d9;
|
--symbol-arcane-text: #6d28d9;
|
||||||
--symbol-arcane-bg: rgba(139, 92, 246, 0.12);
|
--symbol-arcane-bg: rgba(139, 92, 246, 0.12);
|
||||||
|
|
@ -313,9 +319,6 @@ html, body, #root {
|
||||||
background-color: var(--bg-from);
|
background-color: var(--bg-from);
|
||||||
background-image: linear-gradient(to bottom right, var(--bg-from), var(--bg-via), var(--bg-to));
|
background-image: linear-gradient(to bottom right, var(--bg-from), var(--bg-via), var(--bg-to));
|
||||||
background-attachment: fixed;
|
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 {
|
html {
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import NoticeWidget from '../../components/pc/NoticeWidget'
|
import NoticeWidget from '../../components/pc/NoticeWidget'
|
||||||
import SundayMapleBanner from '../../components/pc/SundayMapleBanner'
|
import SundayMapleBanner from '../../components/pc/SundayMapleBanner'
|
||||||
|
import { prefetchUserComponent } from '../../features/registry'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { data: menus = [], isLoading: loading } = useQuery({
|
const { data: menus = [], isLoading: loading } = useQuery({
|
||||||
|
|
@ -64,6 +65,8 @@ export default function Home() {
|
||||||
<Link
|
<Link
|
||||||
key={menu.id}
|
key={menu.id}
|
||||||
to={menu.url}
|
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)]"
|
className="relative rounded-2xl border p-6 transition-transform duration-300 hover:scale-[1.02] border-[var(--card-border)]"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',
|
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue