Compare commits

..

No commits in common. "281332ad14b91fdaf3e86f77fa87c8a7f16ef955" and "dc48f57501694dd8017686cbe888d80c30c98e75" have entirely different histories.

28 changed files with 430 additions and 894 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,217 +0,0 @@
import { useState, useMemo } from 'react'
import dayjs from 'dayjs'
import {
DESTINY_CHAPTERS,
DESTINY_TOTAL,
DESTINY_QUEST_IMAGE_BASE,
DESTINY_BOSSES,
DESTINY_BOSS_IMAGE_BASE,
formatDate,
} from '../data'
import { useLiberationStore, makeEmptyDestinyWeekly } from '../store'
import { calcWeekPoints, calcDoneEarn, computeCompletionDate } from '../utils'
import ProgressBar from './components/ProgressBar'
import QuestSelector from './components/QuestSelector'
import PointsInput from './components/PointsInput'
import WeeklyDefault from './components/WeeklyDefault'
import DatePicker from '../../../components/common/DatePicker'
import ConfirmDialog from '../../../components/common/ConfirmDialog'
export default function Destiny() {
const calcMode = useLiberationStore((s) => s.destinyCalcMode)
const setCalcMode = useLiberationStore((s) => s.setDestinyCalcMode)
const state = useLiberationStore((s) => s.destinyCalcMode === 'weekly' ? s.destinyWeekly : s.destinySimple)
const updateSlot = useLiberationStore((s) => s.updateDestinySlot)
const resetSlot = useLiberationStore((s) => s.resetDestinySlot)
const setState = (updater) => updateSlot(updater)
// : required
const priorConsumed = DESTINY_CHAPTERS
.slice(0, state.startChapter)
.reduce((s, c) => s + c.required, 0)
let cascadeIdx = state.startChapter
let cascadeRemain = state.currentPoints
let cascadeConsumed = 0
while (cascadeIdx < DESTINY_CHAPTERS.length && cascadeRemain >= DESTINY_CHAPTERS[cascadeIdx].required) {
cascadeConsumed += DESTINY_CHAPTERS[cascadeIdx].required
cascadeRemain -= DESTINY_CHAPTERS[cascadeIdx].required
cascadeIdx++
}
const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain
const alreadyDone = initialAccumulated >= DESTINY_TOTAL
const remaining = Math.max(DESTINY_TOTAL - initialAccumulated, 0)
const weeklyEarn = calcWeekPoints(state.weekly, DESTINY_BOSSES)
const doneEarn = calcDoneEarn(state.weekly, DESTINY_BOSSES)
const headerWeekly = calcMode === 'weekly'
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config, DESTINY_BOSSES), 0)
: weeklyEarn
const completionDate = useMemo(
() => computeCompletionDate({
calcMode, state, alreadyDone, remaining,
weeklyEarn, doneEarn,
monthlyEarn: 0,
monthlyDoneThisMonth: false,
bosses: DESTINY_BOSSES,
monthlyBoss: null,
makeEmptyConfig: makeEmptyDestinyWeekly,
}),
[calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn],
)
const isDone = completionDate !== null
const [resetOpen, setResetOpen] = useState(false)
const doReset = () => {
resetSlot()
setResetOpen(false)
}
return (
<>
{/* 계산 모드 탭 */}
<div
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
style={{
background: 'var(--surface-3)',
borderColor: 'var(--panel-border)',
}}
>
{[
{ key: 'simple', label: '일반' },
{ key: 'weekly', label: '주차별' },
].map((t) => {
const active = calcMode === t.key
return (
<button
key={t.key}
type="button"
onClick={() => setCalcMode(t.key)}
className="flex-1 h-10 rounded-lg text-sm font-semibold"
style={active ? {
background: 'var(--selected-bg)',
color: 'var(--accent-bright)',
} : {
color: 'var(--text-muted)',
}}
>
{t.label}
</button>
)
})}
</div>
<ProgressBar
chapters={DESTINY_CHAPTERS}
imageBase={DESTINY_QUEST_IMAGE_BASE}
startChapter={state.startChapter}
currentPoints={state.currentPoints}
completionDate={isDone ? formatDate(completionDate) : null}
completionColor="var(--destiny-date)"
/>
{/* 현재 진행 상태 입력 */}
<div
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
<div className="grid gap-3 grid-cols-3">
<div className="space-y-1.5">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
<DatePicker
value={formatDate(state.startDate)}
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
<QuestSelector
chapters={DESTINY_CHAPTERS}
imageBase={DESTINY_QUEST_IMAGE_BASE}
value={state.startChapter}
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 결의</label>
<div
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
style={{
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
}}
>
<PointsInput
value={state.currentPoints}
max={20000}
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
style={{ color: 'var(--text-strong)' }}
/>
<span
className="flex items-center px-3 text-base border-l select-none tabular-nums"
style={{
borderColor: 'var(--input-border)',
color: 'var(--text-dim)',
}}
>
/ {(DESTINY_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
<WeeklyDefault
bosses={DESTINY_BOSSES}
imageBase={DESTINY_BOSS_IMAGE_BASE}
makeEmptyConfig={makeEmptyDestinyWeekly}
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={headerWeekly}
remaining={remaining}
mode={calcMode}
startDate={state.startDate}
weeks={state.schedulerWeeks}
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
/>
<div className="max-w-3xl mx-auto flex justify-end">
<button
type="button"
onClick={() => setResetOpen(true)}
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
style={{
borderColor: 'var(--icon-danger-border)',
background: 'var(--icon-danger-bg)',
color: 'var(--danger-text)',
}}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
전체 초기화
</button>
</div>
<ConfirmDialog
open={resetOpen}
onClose={() => setResetOpen(false)}
onConfirm={doReset}
title="전체 초기화"
description={`${calcMode === 'simple' ? '일반' : '주차별'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
confirmText="초기화"
destructive
/>
</>
)
}

View file

@ -1,243 +0,0 @@
import { useState, useMemo } from 'react'
import dayjs from 'dayjs'
import {
GENESIS_CHAPTERS,
GENESIS_TOTAL,
WEEKLY_BOSSES,
MONTHLY_BOSSES,
QUEST_BOSS_IMAGE_BASE,
LIBERATION_BOSS_IMAGE_BASE,
formatDate,
} from '../data'
import { useLiberationStore, makeEmptyWeekly } from '../store'
import {
bossEarn,
calcWeekPoints,
calcDoneEarn,
calcMonthlyEarn,
getSchedulerWeekRange,
computeCompletionDate,
} from '../utils'
import QuestSelector from './components/QuestSelector'
import PointsInput from './components/PointsInput'
import ProgressBar from './components/ProgressBar'
import WeeklyDefault from './components/WeeklyDefault'
import DatePicker from '../../../components/common/DatePicker'
import ConfirmDialog from '../../../components/common/ConfirmDialog'
export default function Genesis() {
const calcMode = useLiberationStore((s) => s.genesisCalcMode)
const state = useLiberationStore((s) => s[s.genesisCalcMode])
const setCalcMode = useLiberationStore((s) => s.setGenesisCalcMode)
const updateSlot = useLiberationStore((s) => s.updateSlot)
const resetSlot = useLiberationStore((s) => s.resetSlot)
const setState = (updater) => updateSlot(updater)
// : required
const priorConsumed = GENESIS_CHAPTERS
.slice(0, state.startChapter)
.reduce((s, c) => s + c.required, 0)
let cascadeIdx = state.startChapter
let cascadeRemain = state.currentPoints
let cascadeConsumed = 0
while (cascadeIdx < GENESIS_CHAPTERS.length && cascadeRemain >= GENESIS_CHAPTERS[cascadeIdx].required) {
cascadeConsumed += GENESIS_CHAPTERS[cascadeIdx].required
cascadeRemain -= GENESIS_CHAPTERS[cascadeIdx].required
cascadeIdx++
}
const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
const weeklyEarn = calcWeekPoints(state.weekly)
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
const doneEarn = calcDoneEarn(state.weekly)
const monthlyEarn = calcMonthlyEarn(state.weekly)
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
// ( 1 )
const headerWeekly = calcMode === 'weekly'
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
: weeklyEarn
const headerMonthly = (() => {
if (calcMode !== 'weekly') return monthlyEarn
const sw = state.schedulerWeeks || []
if (!state.startDate) return 0
const claimed = {}
sw.forEach((w, idx) => {
const diff = w.config.blackMage?.difficulty
if (!diff || diff === 'none') return
const r = getSchedulerWeekRange(state.startDate, idx + 1)
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
for (const m of months) {
if (!(m in claimed)) {
claimed[m] = bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
return
}
}
})
return Object.values(claimed).reduce((s, v) => s + v, 0)
})()
const completionDate = useMemo(
() => computeCompletionDate({
calcMode, state, alreadyDone, remaining,
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
}),
[calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth],
)
const isDone = completionDate !== null
const [resetOpen, setResetOpen] = useState(false)
const doReset = () => {
resetSlot()
setResetOpen(false)
}
return (
<>
{/* 계산 모드 탭 */}
<div
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
style={{
background: 'var(--surface-3)',
borderColor: 'var(--panel-border)',
}}
>
{[
{ key: 'simple', label: '일반' },
{ key: 'weekly', label: '주차별' },
].map((t) => {
const active = calcMode === t.key
return (
<button
key={t.key}
type="button"
onClick={() => setCalcMode(t.key)}
className="flex-1 h-10 rounded-lg text-sm font-semibold"
style={active ? {
background: 'var(--selected-bg)',
color: 'var(--accent-bright)',
} : {
color: 'var(--text-muted)',
}}
>
{t.label}
</button>
)
})}
</div>
<ProgressBar
chapters={GENESIS_CHAPTERS}
imageBase={QUEST_BOSS_IMAGE_BASE}
startChapter={state.startChapter}
currentPoints={state.currentPoints}
completionDate={isDone ? formatDate(completionDate) : null}
completionColor="var(--genesis-date)"
/>
{/* 현재 진행 상태 입력 */}
<div
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
<div className="grid gap-3 grid-cols-3">
<div className="space-y-1.5">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
<DatePicker
value={formatDate(state.startDate)}
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
<QuestSelector
chapters={GENESIS_CHAPTERS}
imageBase={QUEST_BOSS_IMAGE_BASE}
value={state.startChapter}
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 흔적</label>
<div
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
style={{
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
}}
>
<PointsInput
value={state.currentPoints}
max={3000}
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
style={{ color: 'var(--text-strong)' }}
/>
<span
className="flex items-center px-3 text-base border-l select-none tabular-nums"
style={{
borderColor: 'var(--input-border)',
color: 'var(--text-dim)',
}}
>
/ {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
<WeeklyDefault
bosses={WEEKLY_BOSSES}
monthlyBosses={MONTHLY_BOSSES}
imageBase={LIBERATION_BOSS_IMAGE_BASE}
makeEmptyConfig={makeEmptyWeekly}
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={headerWeekly}
totalMonthly={headerMonthly}
remaining={remaining}
mode={calcMode}
startDate={state.startDate}
weeks={state.schedulerWeeks}
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
/>
<div className="max-w-3xl mx-auto flex justify-end">
<button
type="button"
onClick={() => setResetOpen(true)}
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
style={{
borderColor: 'var(--icon-danger-border)',
background: 'var(--icon-danger-bg)',
color: 'var(--danger-text)',
}}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
전체 초기화
</button>
</div>
<ConfirmDialog
open={resetOpen}
onClose={() => setResetOpen(false)}
onConfirm={doReset}
title="전체 초기화"
description={`${calcMode === 'simple' ? '일반' : '주차별'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
confirmText="초기화"
destructive
/>
</>
)
}

View file

@ -1,10 +1,29 @@
import { useLayoutEffect } from 'react'
import { useState, useLayoutEffect, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { api } from '../../../api/client'
import { useLayout } from '../../../components/pc/Layout'
import {
GENESIS_CHAPTERS,
GENESIS_TOTAL,
MONTHLY_BOSSES,
formatDate,
} from '../data'
import { useLiberationStore } from '../store'
import Genesis from './Genesis'
import Destiny from './Destiny'
import {
bossEarn,
calcWeekPoints,
calcDoneEarn,
calcMonthlyEarn,
getSchedulerWeekRange,
computeCompletionDate,
} from '../utils'
import QuestSelector from './components/QuestSelector'
import PointsInput from './components/PointsInput'
import ProgressBar from './components/ProgressBar'
import WeeklyDefault from './components/WeeklyDefault'
import DatePicker from '../../../components/common/DatePicker'
import ConfirmDialog from '../../../components/common/ConfirmDialog'
import { useLayout } from '../../../components/pc/Layout'
export default function Liberation() {
const { setFullscreen } = useLayout()
@ -13,8 +32,7 @@ export default function Liberation() {
return () => setFullscreen(false)
}, [setFullscreen])
const liberationType = useLiberationStore((s) => s.liberationType)
const setLiberationType = useLiberationStore((s) => s.setLiberationType)
const [liberationType, setLiberationType] = useState('genesis') // 'genesis' | 'destiny'
const genesisImg = useQuery({
queryKey: ['image', '제네시스 스태프'],
@ -27,6 +45,72 @@ export default function Liberation() {
staleTime: Infinity,
})
const calcMode = useLiberationStore((s) => s.calcMode)
const state = useLiberationStore((s) => s[s.calcMode])
const setCalcMode = useLiberationStore((s) => s.setCalcMode)
const updateSlot = useLiberationStore((s) => s.updateSlot)
const resetSlot = useLiberationStore((s) => s.resetSlot)
const setState = (updater) => updateSlot(updater)
// : required
const priorConsumed = GENESIS_CHAPTERS
.slice(0, state.startChapter)
.reduce((s, c) => s + c.required, 0)
let cascadeIdx = state.startChapter
let cascadeRemain = state.currentPoints
let cascadeConsumed = 0
while (cascadeIdx < GENESIS_CHAPTERS.length && cascadeRemain >= GENESIS_CHAPTERS[cascadeIdx].required) {
cascadeConsumed += GENESIS_CHAPTERS[cascadeIdx].required
cascadeRemain -= GENESIS_CHAPTERS[cascadeIdx].required
cascadeIdx++
}
const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
const weeklyEarn = calcWeekPoints(state.weekly)
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
const doneEarn = calcDoneEarn(state.weekly)
const monthlyEarn = calcMonthlyEarn(state.weekly)
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
// ( 1 )
const headerWeekly = calcMode === 'weekly'
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
: weeklyEarn
const headerMonthly = (() => {
if (calcMode !== 'weekly') return monthlyEarn
const sw = state.schedulerWeeks || []
if (!state.startDate) return 0
const claimed = {}
sw.forEach((w, idx) => {
const diff = w.config.blackMage?.difficulty
if (!diff || diff === 'none') return
const r = getSchedulerWeekRange(state.startDate, idx + 1)
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
for (const m of months) {
if (!(m in claimed)) {
claimed[m] = bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
return
}
}
})
return Object.values(claimed).reduce((s, v) => s + v, 0)
})()
const completionDate = useMemo(
() => computeCompletionDate({
calcMode, state, alreadyDone, remaining,
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
}),
[calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth],
)
const isDone = completionDate !== null
const [resetOpen, setResetOpen] = useState(false)
const doReset = () => {
resetSlot()
setResetOpen(false)
}
return (
<div className="space-y-6 pb-10">
{/* 해방 종류 탭 */}
@ -60,7 +144,156 @@ export default function Liberation() {
})}
</div>
{liberationType === 'genesis' ? <Genesis /> : <Destiny />}
{liberationType === 'destiny' ? (
<div
className="max-w-3xl mx-auto rounded-2xl border p-16 text-center space-y-3 flex flex-col items-center justify-center"
style={{
minHeight: 'calc(100vh - 220px)',
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div className="text-2xl font-bold" style={{ color: 'var(--text-emphasis)' }}>구현 예정</div>
<div className="text-sm" style={{ color: 'var(--text-dim)' }}>데스티니 해방 계산기는 준비 중입니다.</div>
</div>
) : (<>
{/* 계산 모드 탭 */}
<div
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
style={{
background: 'var(--surface-3)',
borderColor: 'var(--panel-border)',
}}
>
{[
{ key: 'simple', label: '단순 계산' },
{ key: 'weekly', label: '주차별 계산' },
].map((t) => {
const active = calcMode === t.key
return (
<button
key={t.key}
type="button"
onClick={() => setCalcMode(t.key)}
className="flex-1 h-10 rounded-lg text-sm font-semibold"
style={active ? {
background: 'var(--selected-bg)',
color: 'var(--accent-bright)',
} : {
color: 'var(--text-muted)',
}}
>
{t.label}
</button>
)
})}
</div>
<ProgressBar
startChapter={state.startChapter}
currentPoints={state.currentPoints}
completionDate={isDone ? formatDate(completionDate) : null}
/>
{/* 현재 진행 상태 입력 */}
<div
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
<div className="grid gap-3 grid-cols-3">
<div className="space-y-1.5">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
<DatePicker
value={formatDate(state.startDate)}
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
<QuestSelector
value={state.startChapter}
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 흔적</label>
<div
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
style={{
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
}}
>
<PointsInput
value={state.currentPoints}
max={3000}
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
style={{ color: 'var(--text-strong)' }}
/>
<span
className="flex items-center px-3 text-base border-l select-none tabular-nums"
style={{
borderColor: 'var(--input-border)',
color: 'var(--text-dim)',
}}
>
/ {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
<WeeklyDefault
weekly={state.weekly}
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
totalWeekly={headerWeekly}
totalMonthly={headerMonthly}
remaining={remaining}
mode={calcMode}
startDate={state.startDate}
weeks={state.schedulerWeeks}
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
/>
<div className="max-w-3xl mx-auto flex justify-end">
<button
type="button"
onClick={() => setResetOpen(true)}
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
style={{
borderColor: 'var(--icon-danger-border)',
background: 'var(--icon-danger-bg)',
color: 'var(--danger-text)',
}}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
전체 초기화
</button>
</div>
</>)}
<ConfirmDialog
open={resetOpen}
onClose={() => setResetOpen(false)}
onConfirm={doReset}
title="전체 초기화"
description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
confirmText="초기화"
destructive
/>
</div>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,17 +11,17 @@ export function bossEarn(boss, sel) {
return calcPoints(d.points, sel.party)
}
export function calcWeekPoints(weekData, bosses = WEEKLY_BOSSES) {
export function calcWeekPoints(weekData) {
let points = 0
bosses.forEach((b) => {
WEEKLY_BOSSES.forEach((b) => {
points += bossEarn(b, weekData.bosses[b.key])
})
return points
}
export function calcDoneEarn(weekData, bosses = WEEKLY_BOSSES) {
export function calcDoneEarn(weekData) {
let points = 0
bosses.forEach((b) => {
WEEKLY_BOSSES.forEach((b) => {
const sel = weekData.bosses[b.key]
if (sel?.done) points += bossEarn(b, sel)
})
@ -64,9 +64,6 @@ export function getSchedulerWeekRange(startDateStr, weekIdx) {
export function computeCompletionDate({
calcMode, state, alreadyDone, remaining,
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
bosses = WEEKLY_BOSSES,
monthlyBoss = MONTHLY_BOSSES[0],
makeEmptyConfig = makeEmptyWeekly,
}) {
if (alreadyDone) return todayKST()
if (remaining <= 0) return dayjs(state.startDate).tz(KST).startOf('day').toDate()
@ -81,23 +78,22 @@ export function computeCompletionDate({
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
// 1주차: 시작일 당일에 (주간 - done) 적립
const week1Cfg = sw[0]?.config || makeEmptyConfig()
const w1Weekly = calcWeekPoints(week1Cfg, bosses)
const w1Done = calcDoneEarn(week1Cfg, bosses)
const week1Cfg = sw[0]?.config || makeEmptyWeekly()
const w1Weekly = calcWeekPoints(week1Cfg)
const w1Done = calcDoneEarn(week1Cfg)
events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) })
// 2주차 이후: 각 목요일에 해당 주차 설정의 주간 합 적립
// 마지막 주차 이후로는 마지막 주차 설정 반복 적용
let nextThu = startKST.add(daysToNextThu, 'day')
for (let i = 1; i < 520; i++) {
const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyConfig()
events.push({ date: nextThu, amount: calcWeekPoints(cfg, bosses) })
const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyWeekly()
events.push({ date: nextThu, amount: calcWeekPoints(cfg) })
nextThu = nextThu.add(1, 'week')
}
// 월간 보스: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립
// 검은 마법사: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립
const claimed = {}
if (monthlyBoss) {
sw.forEach((w, i) => {
const diff = w.config.blackMage?.difficulty
if (!diff || diff === 'none') return
@ -107,7 +103,7 @@ export function computeCompletionDate({
if (!(m in claimed)) {
claimed[m] = {
weekIdx: i,
earn: bossEarn(monthlyBoss, w.config.blackMage),
earn: bossEarn(MONTHLY_BOSSES[0], w.config.blackMage),
done: !!w.config.blackMage.done,
}
return
@ -122,11 +118,10 @@ export function computeCompletionDate({
: startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day')
events.push({ date, amount: info.earn })
})
}
// 마지막 주차 이후로는 마지막 주차의 월간 설정을 매월 반복 적용
// 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용
const lastCfg = sw[sw.length - 1]?.config
const lastBmEarn = monthlyBoss && lastCfg ? bossEarn(monthlyBoss, lastCfg.blackMage) : 0
const lastBmEarn = lastCfg ? bossEarn(MONTHLY_BOSSES[0], lastCfg.blackMage) : 0
if (lastBmEarn > 0) {
const lastWeekStart = sw.length === 1
? startKST
@ -171,20 +166,9 @@ export function computeCompletionDate({
events.sort((a, b) => a.date.diff(b.date))
let cumulative = 0
let lastEventDate = startKST
for (const e of events) {
cumulative += e.amount
lastEventDate = e.date
if (cumulative >= remaining) return e.date.toDate()
}
// 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()
return null
}

View file

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

View file

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

View file

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