Compare commits
40 commits
e418e651b8
...
1dfceaf350
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dfceaf350 | |||
| 46b6237f75 | |||
| fe65c107c8 | |||
| f0a04c51ff | |||
| b63ab39977 | |||
| 8e4c7b8c1b | |||
| e0dd7d1bc4 | |||
| f89d13431a | |||
| d3db14a01c | |||
| 40d045b498 | |||
| ca3c4579ae | |||
| 749e77774a | |||
| 7020794add | |||
| 46ff03ced6 | |||
| 4fa3bdb4a6 | |||
| 2d43b78ce4 | |||
| df0bb7d14b | |||
| 791f4f8e35 | |||
| 3829ada3cf | |||
| 7151315371 | |||
| df057057ff | |||
| 4e1d2556b2 | |||
| d7319c03a3 | |||
| 64411b6a38 | |||
| 2f64941817 | |||
| e01aa99069 | |||
| 73c024b7a7 | |||
| 34a8158074 | |||
| eb4369d8fb | |||
| 33de50bc2d | |||
| c9a130ea65 | |||
| 317754206d | |||
| d69f4f3322 | |||
| aa5db4b4c2 | |||
| 6243dea01e | |||
| ef8f7d5ea4 | |||
| d1ca41ed4a | |||
| 6ca610d014 | |||
| f7481acfa3 | |||
| d3fda95d04 |
44 changed files with 4164 additions and 960 deletions
|
|
@ -2,6 +2,8 @@ import { Image } from './Image.js';
|
|||
import { Menu } from './Menu.js';
|
||||
import { BossCrystalBoss } from './boss-crystal/Boss.js';
|
||||
import { BossCrystalBossDifficulty } from './boss-crystal/BossDifficulty.js';
|
||||
import { Symbol } from './symbol/Symbol.js';
|
||||
import { SymbolLevel } from './symbol/SymbolLevel.js';
|
||||
|
||||
// Menu <-> Image
|
||||
Menu.belongsTo(Image, { foreignKey: 'image_id', as: 'image', onDelete: 'SET NULL' });
|
||||
|
|
@ -14,4 +16,12 @@ BossCrystalBoss.hasMany(BossCrystalBossDifficulty, {
|
|||
});
|
||||
BossCrystalBossDifficulty.belongsTo(BossCrystalBoss, { foreignKey: 'boss_id', as: 'boss' });
|
||||
|
||||
export { Image, Menu, BossCrystalBoss, BossCrystalBossDifficulty };
|
||||
// Symbol <-> SymbolLevel
|
||||
Symbol.hasMany(SymbolLevel, {
|
||||
foreignKey: 'symbol_id',
|
||||
as: 'levels',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
SymbolLevel.belongsTo(Symbol, { foreignKey: 'symbol_id', as: 'symbol' });
|
||||
|
||||
export { Image, Menu, BossCrystalBoss, BossCrystalBossDifficulty, Symbol, SymbolLevel };
|
||||
|
|
|
|||
23
backend/models/symbol/Symbol.js
Normal file
23
backend/models/symbol/Symbol.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../../lib/db.js';
|
||||
|
||||
export const Symbol = sequelize.define('Symbol', {
|
||||
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||
type: {
|
||||
type: DataTypes.ENUM('아케인', '어센틱', '그랜드 어센틱'),
|
||||
allowNull: false,
|
||||
},
|
||||
region: { type: DataTypes.STRING(32), allowNull: false },
|
||||
image: { type: DataTypes.STRING(255), allowNull: true },
|
||||
max_level: { type: DataTypes.TINYINT, allowNull: false },
|
||||
daily_default: { type: DataTypes.SMALLINT, allowNull: false, defaultValue: 0 },
|
||||
weekly_default: { type: DataTypes.SMALLINT, allowNull: false, defaultValue: 0 },
|
||||
sort_order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
}, {
|
||||
tableName: 'sym_symbols',
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{ unique: true, fields: ['type', 'region'] },
|
||||
{ fields: ['sort_order'] },
|
||||
],
|
||||
});
|
||||
16
backend/models/symbol/SymbolLevel.js
Normal file
16
backend/models/symbol/SymbolLevel.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../../lib/db.js';
|
||||
|
||||
export const SymbolLevel = sequelize.define('SymbolLevel', {
|
||||
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||
symbol_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||
level: { type: DataTypes.TINYINT, allowNull: false },
|
||||
required_count: { type: DataTypes.SMALLINT, allowNull: false },
|
||||
meso_cost: { type: DataTypes.BIGINT, allowNull: false },
|
||||
}, {
|
||||
tableName: 'sym_levels',
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{ unique: true, fields: ['symbol_id', 'level'] },
|
||||
],
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ 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';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({
|
||||
|
|
@ -35,6 +36,7 @@ router.use(requireAdmin);
|
|||
|
||||
// 기능별 sub-router
|
||||
router.use('/boss-crystal', bossCrystalRouter);
|
||||
router.use('/symbol', symbolRouter);
|
||||
|
||||
/* ── 이미지 관리 ── */
|
||||
|
||||
|
|
|
|||
237
backend/routes/admin/symbol.js
Normal file
237
backend/routes/admin/symbol.js
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { Symbol, SymbolLevel } from '../../models/index.js';
|
||||
import { convertAndUploadTo, deleteFromS3 } from '../../services/image.js';
|
||||
import { getPublicUrl } from '../../lib/s3.js';
|
||||
import { sequelize } from '../../lib/db.js';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
const VALID_TYPES = ['아케인', '어센틱', '그랜드 어센틱'];
|
||||
|
||||
function imagePath(type, region) {
|
||||
return `symbol/${type}심볼(${region}).webp`;
|
||||
}
|
||||
|
||||
function serialize(symbol) {
|
||||
const json = symbol.toJSON();
|
||||
return {
|
||||
id: json.id,
|
||||
type: json.type,
|
||||
region: json.region,
|
||||
image: json.image,
|
||||
image_url: json.image ? getPublicUrl(json.image) : null,
|
||||
max_level: json.max_level,
|
||||
daily_default: json.daily_default,
|
||||
weekly_default: json.weekly_default,
|
||||
sort_order: json.sort_order,
|
||||
levels: (json.levels || [])
|
||||
.sort((a, b) => a.level - b.level)
|
||||
.map((l) => ({
|
||||
level: l.level,
|
||||
required_count: l.required_count,
|
||||
meso_cost: Number(l.meso_cost),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function parseLevels(raw, maxLevel) {
|
||||
if (!raw) return [];
|
||||
let arr;
|
||||
try {
|
||||
arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
throw new Error('레벨 정보 형식이 잘못되었습니다');
|
||||
}
|
||||
if (!Array.isArray(arr)) throw new Error('레벨 정보는 배열이어야 합니다');
|
||||
return arr.map((l) => {
|
||||
const level = Number(l.level);
|
||||
const required_count = Number(l.required_count);
|
||||
const meso_cost = Number(l.meso_cost);
|
||||
if (!level || level < 1 || level >= maxLevel) {
|
||||
throw new Error(`잘못된 레벨: ${l.level}`);
|
||||
}
|
||||
if (isNaN(required_count) || required_count < 0) {
|
||||
throw new Error(`잘못된 필요 개수: Lv.${level}`);
|
||||
}
|
||||
if (isNaN(meso_cost) || meso_cost < 0) {
|
||||
throw new Error(`잘못된 메소: Lv.${level}`);
|
||||
}
|
||||
return { level, required_count, meso_cost };
|
||||
});
|
||||
}
|
||||
|
||||
function validateBasic({ type, region, max_level }) {
|
||||
if (!VALID_TYPES.includes(type)) throw new Error('잘못된 심볼 종류입니다');
|
||||
const r = String(region || '').trim();
|
||||
if (!r) throw new Error('지역 이름을 입력해주세요');
|
||||
const ml = Number(max_level);
|
||||
if (!ml || ml < 2 || ml > 99) throw new Error('만렙은 2~99 사이여야 합니다');
|
||||
return { type, region: r, max_level: ml };
|
||||
}
|
||||
|
||||
// 목록
|
||||
router.get('/symbols', async (_req, res) => {
|
||||
try {
|
||||
const rows = await Symbol.findAll({
|
||||
order: [['sort_order', 'ASC'], ['id', 'ASC']],
|
||||
include: [{ model: SymbolLevel, as: 'levels' }],
|
||||
});
|
||||
res.json(rows.map(serialize));
|
||||
} catch (err) {
|
||||
console.error('심볼 목록 조회 오류:', err.message);
|
||||
res.status(500).json({ error: '심볼 목록 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 단건
|
||||
router.get('/symbols/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await Symbol.findByPk(req.params.id, {
|
||||
include: [{ model: SymbolLevel, as: 'levels' }],
|
||||
});
|
||||
if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' });
|
||||
res.json(serialize(row));
|
||||
} catch (err) {
|
||||
console.error('심볼 조회 오류:', err.message);
|
||||
res.status(500).json({ error: '심볼 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 생성
|
||||
router.post('/symbols', upload.single('image'), async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const basic = validateBasic(req.body);
|
||||
const levels = parseLevels(req.body.levels, basic.max_level);
|
||||
const daily_default = Number(req.body.daily_default) || 0;
|
||||
const weekly_default = Number(req.body.weekly_default) || 0;
|
||||
|
||||
if (!req.file) throw new Error('심볼 이미지를 업로드해주세요');
|
||||
const key = imagePath(basic.type, basic.region);
|
||||
await convertAndUploadTo(req.file.buffer, key);
|
||||
|
||||
const maxOrder = (await Symbol.max('sort_order')) || 0;
|
||||
const created = await Symbol.create({
|
||||
type: basic.type,
|
||||
region: basic.region,
|
||||
image: key,
|
||||
max_level: basic.max_level,
|
||||
daily_default,
|
||||
weekly_default,
|
||||
sort_order: maxOrder + 1,
|
||||
}, { transaction: t });
|
||||
|
||||
if (levels.length) {
|
||||
await SymbolLevel.bulkCreate(
|
||||
levels.map((l) => ({ symbol_id: created.id, ...l })),
|
||||
{ transaction: t }
|
||||
);
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
const full = await Symbol.findByPk(created.id, { include: [{ model: SymbolLevel, as: 'levels' }] });
|
||||
res.status(201).json(serialize(full));
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('심볼 생성 오류:', err.message);
|
||||
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||
return res.status(409).json({ error: '이미 등록된 종류+지역 조합입니다' });
|
||||
}
|
||||
res.status(400).json({ error: err.message || '심볼 생성 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 수정
|
||||
router.patch('/symbols/:id', upload.single('image'), async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const row = await Symbol.findByPk(req.params.id);
|
||||
if (!row) { await t.rollback(); return res.status(404).json({ error: '심볼을 찾을 수 없습니다' }); }
|
||||
|
||||
const basic = validateBasic({
|
||||
type: req.body.type ?? row.type,
|
||||
region: req.body.region ?? row.region,
|
||||
max_level: req.body.max_level ?? row.max_level,
|
||||
});
|
||||
|
||||
let imageKey = row.image;
|
||||
if (req.file) {
|
||||
imageKey = imagePath(basic.type, basic.region);
|
||||
await convertAndUploadTo(req.file.buffer, imageKey);
|
||||
if (row.image && row.image !== imageKey) {
|
||||
try { await deleteFromS3(row.image); } catch { /* ignore */ }
|
||||
}
|
||||
} else if (basic.type !== row.type || basic.region !== row.region) {
|
||||
// 이름/종류 변경 시 새 경로로 rename 대체 불가 → 기존 키 유지
|
||||
imageKey = row.image;
|
||||
}
|
||||
|
||||
await row.update({
|
||||
type: basic.type,
|
||||
region: basic.region,
|
||||
max_level: basic.max_level,
|
||||
image: imageKey,
|
||||
daily_default: Number(req.body.daily_default) || 0,
|
||||
weekly_default: Number(req.body.weekly_default) || 0,
|
||||
}, { transaction: t });
|
||||
|
||||
if (req.body.levels !== undefined) {
|
||||
const levels = parseLevels(req.body.levels, basic.max_level);
|
||||
await SymbolLevel.destroy({ where: { symbol_id: row.id }, transaction: t });
|
||||
if (levels.length) {
|
||||
await SymbolLevel.bulkCreate(
|
||||
levels.map((l) => ({ symbol_id: row.id, ...l })),
|
||||
{ transaction: t }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
const full = await Symbol.findByPk(row.id, { include: [{ model: SymbolLevel, as: 'levels' }] });
|
||||
res.json(serialize(full));
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('심볼 수정 오류:', err.message);
|
||||
res.status(400).json({ error: err.message || '심볼 수정 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 삭제
|
||||
router.delete('/symbols/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await Symbol.findByPk(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' });
|
||||
const key = row.image;
|
||||
await row.destroy();
|
||||
if (key) { try { await deleteFromS3(key); } catch { /* ignore */ } }
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('심볼 삭제 오류:', err.message);
|
||||
res.status(500).json({ error: '심볼 삭제 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 순서 변경
|
||||
router.post('/symbols/reorder', async (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids 배열이 필요합니다' });
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await Symbol.update({ sort_order: i + 1 }, { where: { id: ids[i] }, transaction: t });
|
||||
}
|
||||
await t.commit();
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('심볼 순서 변경 오류:', err.message);
|
||||
res.status(500).json({ error: '순서 변경 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -31,6 +31,10 @@ router.get('/search', async (req, res) => {
|
|||
character_image: basic.character_image,
|
||||
});
|
||||
} catch (err) {
|
||||
const code = err.response?.data?.error?.name;
|
||||
if (['OPENAPI00001', 'OPENAPI00007', 'OPENAPI00010', 'OPENAPI00011'].includes(code)) {
|
||||
return res.status(503).json({ error: 'API 점검중입니다', code, maintenance: true });
|
||||
}
|
||||
if (err.response?.status === 400) {
|
||||
return res.status(404).json({ error: '존재하지 않는 캐릭터입니다' });
|
||||
}
|
||||
|
|
@ -39,4 +43,39 @@ router.get('/search', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// OCID로 장착 심볼 조회
|
||||
router.get('/symbols', async (req, res) => {
|
||||
const { ocid } = req.query;
|
||||
if (!ocid) return res.status(400).json({ error: 'ocid가 필요합니다' });
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/symbol-equipment`, {
|
||||
params: { ocid },
|
||||
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
|
||||
});
|
||||
|
||||
const parsed = (data.symbol || []).map((s) => {
|
||||
const [prefix, region] = (s.symbol_name || '').split(' : ').map((t) => t.trim());
|
||||
const type = prefix?.replace(/심볼$/, '').trim(); // '아케인심볼' → '아케인'
|
||||
return {
|
||||
type,
|
||||
region,
|
||||
level: Number(s.symbol_level) || 0,
|
||||
force: Number(s.symbol_force) || 0,
|
||||
growth_count: Number(s.symbol_growth_count) || 0,
|
||||
require_growth_count: Number(s.symbol_require_growth_count) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ ocid, character_class: data.character_class, symbols: parsed });
|
||||
} catch (err) {
|
||||
const code = err.response?.data?.error?.name;
|
||||
if (['OPENAPI00001', 'OPENAPI00007', 'OPENAPI00010', 'OPENAPI00011'].includes(code)) {
|
||||
return res.status(503).json({ error: 'API 점검중입니다', code, maintenance: true });
|
||||
}
|
||||
console.error('심볼 조회 오류:', err.response?.data || err.message);
|
||||
res.status(500).json({ error: '심볼 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ router.get('/', async (req, res) => {
|
|||
});
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
const errData = err.response?.data?.error;
|
||||
const code = errData?.name;
|
||||
// Nexon 점검 코드: OPENAPI00001(게임 점검), OPENAPI00007(api 점검), OPENAPI00011(오픈 API 점검)
|
||||
const underMaintenance = ['OPENAPI00001', 'OPENAPI00007', 'OPENAPI00010', 'OPENAPI00011'].includes(code);
|
||||
if (underMaintenance) {
|
||||
return res.status(503).json({ error: 'API 점검중입니다', code, maintenance: true });
|
||||
}
|
||||
console.error(`공지 조회 오류 (${type}):`, err.response?.data || err.message);
|
||||
res.status(500).json({ error: '공지 조회 실패' });
|
||||
}
|
||||
|
|
|
|||
39
backend/routes/symbol.js
Normal file
39
backend/routes/symbol.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Router } from 'express';
|
||||
import { Symbol, SymbolLevel } from '../models/index.js';
|
||||
import { getPublicUrl } from '../lib/s3.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const rows = await Symbol.findAll({
|
||||
order: [['sort_order', 'ASC'], ['id', 'ASC']],
|
||||
include: [{ model: SymbolLevel, as: 'levels' }],
|
||||
});
|
||||
res.json(rows.map((s) => {
|
||||
const j = s.toJSON();
|
||||
return {
|
||||
id: j.id,
|
||||
type: j.type,
|
||||
region: j.region,
|
||||
image_url: j.image ? getPublicUrl(j.image) : null,
|
||||
max_level: j.max_level,
|
||||
daily_default: j.daily_default,
|
||||
weekly_default: j.weekly_default,
|
||||
sort_order: j.sort_order,
|
||||
levels: (j.levels || [])
|
||||
.sort((a, b) => a.level - b.level)
|
||||
.map((l) => ({
|
||||
level: l.level,
|
||||
required_count: l.required_count,
|
||||
meso_cost: Number(l.meso_cost),
|
||||
})),
|
||||
};
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('심볼 목록 조회 오류:', err.message);
|
||||
res.status(500).json({ error: '심볼 목록 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -6,6 +6,7 @@ import noticeRoutes from './routes/notices.js';
|
|||
import bossCrystalRoutes from './routes/boss-crystal.js';
|
||||
import characterRoutes from './routes/character.js';
|
||||
import imageRoutes from './routes/images.js';
|
||||
import symbolRoutes from './routes/symbol.js';
|
||||
import { sequelize } from './lib/db.js';
|
||||
import './models/index.js';
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ app.use('/api/notices', noticeRoutes);
|
|||
app.use('/api/boss-crystal', bossCrystalRoutes);
|
||||
app.use('/api/character', characterRoutes);
|
||||
app.use('/api/images', imageRoutes);
|
||||
app.use('/api/symbols', symbolRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
|
|
|
|||
|
|
@ -29,3 +29,14 @@ export async function convertAndUpload(buffer) {
|
|||
export async function deleteFromS3(path) {
|
||||
await deleteObject(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정한 경로로 webp 변환 후 업로드 (덮어쓰기)
|
||||
* @param {Buffer} buffer - 원본 이미지 버퍼
|
||||
* @param {string} path - S3 키 (확장자 포함). 예: 'symbol/아케인심볼(소멸의 여로).webp'
|
||||
*/
|
||||
export async function convertAndUploadTo(buffer, path) {
|
||||
const webpBuffer = await sharp(buffer).webp({ quality: 90 }).toBuffer();
|
||||
await uploadObject(path, webpBuffer, 'image/webp');
|
||||
return { path, size: webpBuffer.length };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@
|
|||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;900&display=swap" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/gh/fonts-archive/Maplestory/Maplestory.css" rel="stylesheet" />
|
||||
<script type="text/javascript" src="https://openapi.nexon.com/js/analytics.js?app_id=274844" async></script>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var raw = localStorage.getItem('maple-theme');
|
||||
if (!raw) return;
|
||||
var theme = JSON.parse(raw).state && JSON.parse(raw).state.theme;
|
||||
if (theme === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<title>메이플스토리 유틸리티</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
32
frontend/package-lock.json
generated
32
frontend/package-lock.json
generated
|
|
@ -18,7 +18,8 @@
|
|||
"overlayscrollbars-react": "^0.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0"
|
||||
"react-router-dom": "^7.14.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
|
@ -3139,6 +3140,35 @@
|
|||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
|
||||
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
"overlayscrollbars-react": "^0.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0"
|
||||
"react-router-dom": "^7.14.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ export async function api(url, options = {}) {
|
|||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}))
|
||||
throw new Error(error.error || `HTTP ${res.status}`)
|
||||
const e = new Error(error.error || `HTTP ${res.status}`)
|
||||
Object.assign(e, error, { status: res.status })
|
||||
throw e
|
||||
}
|
||||
|
||||
return res.json()
|
||||
|
|
|
|||
|
|
@ -12,8 +12,18 @@ export default function ConfirmDialog({
|
|||
loading = false,
|
||||
}) {
|
||||
const accent = destructive
|
||||
? { ring: 'ring-red-500/20', icon: 'text-red-300', iconBg: 'bg-red-500/10 border-red-500/30' }
|
||||
: { ring: 'ring-emerald-500/20', icon: 'text-emerald-300', iconBg: 'bg-emerald-500/10 border-emerald-500/30' }
|
||||
? {
|
||||
ringColor: 'var(--ring-danger)',
|
||||
iconColor: 'var(--danger-text)',
|
||||
iconBg: 'var(--icon-danger-bg)',
|
||||
iconBorder: 'var(--icon-danger-border)',
|
||||
}
|
||||
: {
|
||||
ringColor: 'var(--ring-info)',
|
||||
iconColor: 'var(--accent-bright)',
|
||||
iconBg: 'var(--icon-info-bg)',
|
||||
iconBorder: 'var(--icon-info-border)',
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
|
|
@ -24,7 +34,8 @@ export default function ConfirmDialog({
|
|||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-md"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-md"
|
||||
style={{ background: 'var(--dialog-backdrop)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
|
|
@ -33,48 +44,82 @@ export default function ConfirmDialog({
|
|||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 4 }}
|
||||
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={`w-full max-w-md rounded-2xl bg-gradient-to-b from-gray-900 to-gray-950 border border-white/10 shadow-2xl ring-1 ${accent.ring}`}
|
||||
className="w-full max-w-md rounded-2xl border shadow-2xl ring-1"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
|
||||
borderColor: 'var(--dialog-border)',
|
||||
'--tw-ring-color': accent.ringColor,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-7 pt-7 pb-3 flex items-start gap-4">
|
||||
<div className={`shrink-0 w-11 h-11 rounded-xl border flex items-center justify-center ${accent.iconBg}`}>
|
||||
<div
|
||||
className="shrink-0 w-11 h-11 rounded-xl border flex items-center justify-center"
|
||||
style={{ background: accent.iconBg, borderColor: accent.iconBorder, color: accent.iconColor }}
|
||||
>
|
||||
{destructive ? (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" className={accent.icon}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 9V13M12 17H12.01M10.29 3.86L1.82 18C1.64 18.31 1.55 18.67 1.55 19.03C1.55 19.4 1.65 19.76 1.83 20.07C2 20.39 2.26 20.65 2.57 20.83C2.88 21.01 3.24 21.1 3.6 21.1H20.47C20.83 21.1 21.19 21.01 21.5 20.83C21.81 20.65 22.07 20.39 22.24 20.07C22.42 19.76 22.52 19.4 22.52 19.03C22.52 18.67 22.43 18.31 22.25 18L13.78 3.86C13.6 3.56 13.35 3.31 13.04 3.14C12.74 2.96 12.4 2.87 12.06 2.87C11.72 2.87 11.38 2.96 11.08 3.14C10.77 3.31 10.52 3.56 10.34 3.86H10.29Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" className={accent.icon}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 8V12M12 16H12.01M22 12C22 17.52 17.52 22 12 22C6.48 22 2 17.52 2 12C2 6.48 6.48 2 12 2C17.52 2 22 6.48 22 12Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="flex-1 text-xl font-bold text-white pt-1.5">{title}</h3>
|
||||
<h3
|
||||
className="flex-1 text-xl font-bold pt-1.5"
|
||||
style={{ color: 'var(--text-strong)' }}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 w-8 h-8 -mt-1 -mr-1 rounded-lg text-gray-500 hover:text-white hover:bg-white/5 transition flex items-center justify-center text-xl leading-none"
|
||||
className="shrink-0 w-8 h-8 -mt-1 -mr-1 rounded-lg hover:bg-[var(--row-hover-bg)] flex items-center justify-center text-xl leading-none"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
aria-label="닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-7 pt-4 pb-7">
|
||||
<p className="text-lg text-gray-300 leading-relaxed whitespace-pre-line">{description}</p>
|
||||
<p
|
||||
className="text-lg leading-relaxed whitespace-pre-line"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 px-7 py-4 border-t border-white/5">
|
||||
<div
|
||||
className="flex gap-2 px-7 py-4 border-t"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-white/[0.02] hover:bg-white/[0.06] text-gray-200 px-4 h-11 text-sm font-medium transition"
|
||||
className="flex-1 rounded-lg border px-4 h-11 text-sm font-medium hover:bg-[var(--btn-bg-hover)] hover:border-[var(--btn-border-hover)]"
|
||||
style={{
|
||||
background: 'var(--btn-bg)',
|
||||
borderColor: 'var(--btn-border)',
|
||||
color: 'var(--text-emphasis)',
|
||||
}}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={`flex-1 rounded-lg px-4 h-11 text-sm font-semibold transition disabled:opacity-50 ${
|
||||
destructive
|
||||
? 'bg-red-600 hover:bg-red-500 text-white shadow-lg shadow-red-500/20'
|
||||
: 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20'
|
||||
}`}
|
||||
className="flex-1 rounded-lg px-4 h-11 text-sm font-semibold disabled:opacity-50"
|
||||
style={{
|
||||
background: destructive ? 'var(--btn-danger-bg)' : 'var(--btn-primary-bg)',
|
||||
color: destructive ? 'var(--btn-primary-text)' : 'var(--btn-primary-text)',
|
||||
boxShadow: destructive ? 'var(--btn-danger-shadow)' : 'var(--btn-primary-shadow)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = destructive ? 'var(--btn-danger-bg-hover)' : 'var(--btn-primary-bg-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = destructive ? 'var(--btn-danger-bg)' : 'var(--btn-primary-bg)'
|
||||
}}
|
||||
>
|
||||
{loading ? '처리 중...' : confirmText}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -69,10 +69,12 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
const selectYear = (y) => setViewDate(new Date(y, month, 1))
|
||||
const selectMonth = (m) => { setViewDate(new Date(year, m, 1)); setViewMode('days') }
|
||||
|
||||
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
||||
const formatDisplay = (s) => {
|
||||
if (!s) return ''
|
||||
const [y, m, d] = s.split('-')
|
||||
return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일`
|
||||
const dow = DOW[new Date(`${s}T00:00:00+09:00`).getDay()]
|
||||
return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일 (${dow})`
|
||||
}
|
||||
|
||||
const isSelected = (day) => {
|
||||
|
|
@ -95,14 +97,19 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => setIsOpen(!isOpen))}
|
||||
className={`w-full h-12 rounded-lg border bg-gray-950 px-4 text-base flex items-center justify-between transition ${
|
||||
isOpen ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
className="w-full h-12 rounded-lg border px-4 text-base flex items-center justify-between"
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: isOpen ? 'var(--input-border-focus)' : 'var(--input-border)',
|
||||
}}
|
||||
>
|
||||
<span className={value ? 'text-white' : 'text-gray-500'}>
|
||||
<span style={{ color: value ? 'var(--text-strong)' : 'var(--input-placeholder)' }}>
|
||||
{value ? formatDisplay(value) : placeholder}
|
||||
</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" className="text-gray-400">
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
style={{ color: 'var(--input-icon)' }}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
|
|
@ -115,22 +122,29 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-50 mt-2 left-0 rounded-xl border border-white/10 bg-gray-900 shadow-2xl p-5"
|
||||
style={{ width: 420 }}
|
||||
className="absolute z-50 mt-2 left-0 rounded-xl border p-5"
|
||||
style={{
|
||||
width: 420,
|
||||
background: 'var(--popup-bg)',
|
||||
borderColor: 'var(--popup-border)',
|
||||
boxShadow: 'var(--popup-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, viewMode === 'years' ? prevYearRange : prevMonth)}
|
||||
disabled={viewMode === 'years' ? !canGoPrevYearRange : (year === minYear && month === 0)}
|
||||
className="p-1.5 rounded hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed text-gray-400"
|
||||
className="p-1.5 rounded hover:bg-[var(--row-hover-bg)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<ChevronIcon dir="left" size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => setViewMode(viewMode === 'days' ? 'years' : 'days'))}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-200 hover:text-emerald-300 transition"
|
||||
className="flex items-center gap-1 text-sm font-medium hover:text-[var(--accent-bright)]"
|
||||
style={{ color: 'var(--text-emphasis)' }}
|
||||
>
|
||||
{viewMode === 'years' ? `${years[0]} - ${years[years.length - 1]}` : `${year}년 ${month + 1}월`}
|
||||
<ChevronIcon dir={viewMode !== 'days' ? 'up' : 'down'} size={14} className="transition-transform" />
|
||||
|
|
@ -138,7 +152,8 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, viewMode === 'years' ? nextYearRange : nextMonth)}
|
||||
className="p-1.5 rounded hover:bg-white/5 text-gray-400"
|
||||
className="p-1.5 rounded hover:bg-[var(--row-hover-bg)]"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<ChevronIcon dir="right" size={18} />
|
||||
</button>
|
||||
|
|
@ -147,43 +162,51 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
<AnimatePresence mode="wait">
|
||||
{viewMode === 'years' ? (
|
||||
<motion.div key="years" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.12 }}>
|
||||
<div className="text-center text-xs text-gray-500 mb-2">연도</div>
|
||||
<div className="text-center text-xs mb-2" style={{ color: 'var(--text-dim)' }}>연도</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: '6px', marginBottom: '12px' }}>
|
||||
{years.map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => selectYear(y))}
|
||||
className={`py-2 rounded-lg text-sm transition ${
|
||||
year === y
|
||||
? 'bg-emerald-500 text-white'
|
||||
: currentYear === y
|
||||
? 'text-emerald-300 hover:bg-white/5'
|
||||
: 'text-gray-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
{years.map((y) => {
|
||||
const isActive = year === y
|
||||
const isCurrent = currentYear === y && !isActive
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => selectYear(y))}
|
||||
className="py-2 rounded-lg text-sm hover:bg-[var(--row-hover-bg)]"
|
||||
style={isActive ? {
|
||||
background: 'var(--btn-primary-bg)',
|
||||
color: 'var(--btn-primary-text)',
|
||||
} : {
|
||||
color: isCurrent ? 'var(--accent-bright)' : 'var(--text-emphasis)',
|
||||
}}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-center text-xs text-gray-500 mb-2">월</div>
|
||||
<div className="text-center text-xs mb-2" style={{ color: 'var(--text-dim)' }}>월</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: '6px' }}>
|
||||
{monthNames.map((m, i) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => selectMonth(i))}
|
||||
className={`py-2 rounded-lg text-sm transition ${
|
||||
month === i
|
||||
? 'bg-emerald-500 text-white'
|
||||
: (currentYear === year && currentMonth === i)
|
||||
? 'text-emerald-300 hover:bg-white/5'
|
||||
: 'text-gray-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
{monthNames.map((m, i) => {
|
||||
const isActive = month === i
|
||||
const isCurrent = (currentYear === year && currentMonth === i) && !isActive
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => selectMonth(i))}
|
||||
className="py-2 rounded-lg text-sm hover:bg-[var(--row-hover-bg)]"
|
||||
style={isActive ? {
|
||||
background: 'var(--btn-primary-bg)',
|
||||
color: 'var(--btn-primary-text)',
|
||||
} : {
|
||||
color: isCurrent ? 'var(--accent-bright)' : 'var(--text-emphasis)',
|
||||
}}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
|
|
@ -192,9 +215,11 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
|
||||
<div
|
||||
key={d}
|
||||
className={`text-center text-xs font-medium py-1 ${
|
||||
i === 0 ? 'text-red-400/80' : i === 6 ? 'text-sky-400/80' : 'text-gray-500'
|
||||
}`}
|
||||
className="text-center text-xs font-medium py-1"
|
||||
style={{
|
||||
color: i === 0 ? 'var(--danger-text)' : i === 6 ? '#60a5fa' : 'var(--text-dim)',
|
||||
opacity: i === 0 || i === 6 ? 0.8 : 1,
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
|
|
@ -205,20 +230,25 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
const dw = i % 7
|
||||
const selected = isSelected(day)
|
||||
const today = isToday(day)
|
||||
const textColor = today && !selected ? 'var(--accent-bright)'
|
||||
: day && !selected && !today && dw === 0 ? 'var(--danger-text)'
|
||||
: day && !selected && !today && dw === 6 ? '#60a5fa'
|
||||
: day && !selected && !today ? 'var(--text-emphasis)'
|
||||
: undefined
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={!day}
|
||||
onClick={(e) => day && stop(e, () => selectDate(day))}
|
||||
style={{ aspectRatio: '1 / 1' }}
|
||||
className={`rounded-full text-base font-medium flex items-center justify-center transition-all
|
||||
${!day ? '' : 'hover:bg-white/5'}
|
||||
${selected ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}
|
||||
${today && !selected ? 'text-emerald-300 font-bold' : ''}
|
||||
${day && !selected && !today && dw === 0 ? 'text-red-400' : ''}
|
||||
${day && !selected && !today && dw === 6 ? 'text-sky-400' : ''}
|
||||
${day && !selected && !today && dw > 0 && dw < 6 ? 'text-gray-300' : ''}
|
||||
style={{
|
||||
aspectRatio: '1 / 1',
|
||||
background: selected ? 'var(--btn-primary-bg)' : undefined,
|
||||
color: selected ? 'var(--btn-primary-text)' : textColor,
|
||||
fontWeight: today && !selected ? 'bold' : undefined,
|
||||
}}
|
||||
className={`rounded-full text-base font-medium flex items-center justify-center
|
||||
${!day ? '' : 'hover:bg-[var(--row-hover-bg)]'}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-white/5 mt-16">
|
||||
<footer
|
||||
className="border-t mt-16"
|
||||
style={{ borderColor: 'var(--header-border)' }}
|
||||
>
|
||||
<div className="mx-auto max-w-5xl px-6 py-8 space-y-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src="/favicon.ico" alt="" className="w-6 h-6" />
|
||||
<span className="font-bold text-sm">메이플스토리 유틸리티</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 text-xs text-gray-500">
|
||||
<div
|
||||
className="grid gap-2 sm:grid-cols-2 text-xs"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p>This site is not associated with NEXON Korea.</p>
|
||||
<p>
|
||||
Data based on{' '}
|
||||
<a href="https://openapi.nexon.com" target="_blank" rel="noopener noreferrer" className="text-emerald-400/80 hover:text-emerald-300 transition">
|
||||
<a href="https://openapi.nexon.com" target="_blank" rel="noopener noreferrer" className="text-emerald-500 hover:text-emerald-400 transition">
|
||||
NEXON Open API
|
||||
</a>
|
||||
.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom'
|
||||
import { Outlet, Link, useLocation, useMatch } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import Footer from './Footer'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
|
||||
const SITE_NAME = '메이플스토리 유틸리티'
|
||||
|
||||
|
|
@ -40,30 +41,96 @@ function CurrentMenuTitle() {
|
|||
if (!menu) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<span className="text-white/20">/</span>
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<span style={{ color: 'var(--text-slash)' }}>/</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{menu.image?.url && (
|
||||
<img src={menu.image.url} alt="" className="w-5 h-5 object-contain" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-200">{menu.title}</span>
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--text-emphasis)' }}
|
||||
>
|
||||
{menu.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeToggle() {
|
||||
const theme = useThemeStore((s) => s.theme)
|
||||
const toggleTheme = useThemeStore((s) => s.toggleTheme)
|
||||
const isLight = theme === 'light'
|
||||
|
||||
const handleToggle = () => toggleTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-label={isLight ? '다크 모드로 전환' : '라이트 모드로 전환'}
|
||||
title={isLight ? '다크 모드' : '라이트 모드'}
|
||||
className="relative inline-flex h-8 w-14 items-center rounded-full border transition-colors duration-500 hover:border-emerald-500/40"
|
||||
style={{
|
||||
background: 'var(--toggle-bg)',
|
||||
borderColor: 'var(--toggle-border)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full shadow-md transition-[transform,background] duration-300 ease-out"
|
||||
style={{
|
||||
transform: isLight ? 'translateX(24px)' : 'translateX(0px)',
|
||||
backgroundImage: 'linear-gradient(to bottom right, var(--toggle-thumb-from), var(--toggle-thumb-to))',
|
||||
color: 'var(--toggle-thumb-icon)',
|
||||
}}
|
||||
>
|
||||
{isLight ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
|
||||
<path fillRule="evenodd" d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zM10 15a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zM18 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0118 10zM5 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 015 10zM15.657 4.343a.75.75 0 010 1.06l-1.06 1.061a.75.75 0 11-1.061-1.06l1.06-1.061a.75.75 0 011.061 0zM6.464 13.536a.75.75 0 010 1.06l-1.06 1.061a.75.75 0 01-1.061-1.06l1.06-1.061a.75.75 0 011.061 0zM15.657 15.657a.75.75 0 01-1.06 0l-1.061-1.06a.75.75 0 011.06-1.061l1.061 1.06a.75.75 0 010 1.061zM6.464 6.464a.75.75 0 01-1.06 0L4.343 5.404a.75.75 0 011.06-1.06l1.061 1.06a.75.75 0 010 1.06zM10 6a4 4 0 100 8 4 4 0 000-8z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
|
||||
<path fillRule="evenodd" d="M7.455 2.004a.75.75 0 01.26.77 7 7 0 009.958 7.967.75.75 0 011.067.853A8.5 8.5 0 116.647 1.921a.75.75 0 01.808.083z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const isAdmin = !!useMatch('/admin/*')
|
||||
const homeTo = isAdmin ? '/admin' : '/'
|
||||
const theme = useThemeStore((s) => s.theme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === 'light') root.setAttribute('data-theme', 'light')
|
||||
else root.removeAttribute('data-theme')
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={{ fullscreen, setFullscreen }}>
|
||||
<div className={`min-w-[1280px] text-white flex flex-col ${
|
||||
fullscreen ? 'h-dvh' : 'min-h-screen'
|
||||
}`}>
|
||||
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md shrink-0">
|
||||
<div
|
||||
className={`min-w-[1280px] flex flex-col ${
|
||||
fullscreen ? 'h-dvh' : 'min-h-screen'
|
||||
}`}
|
||||
style={{ color: 'var(--text-strong)' }}
|
||||
>
|
||||
<header
|
||||
className="sticky top-0 z-20 border-b backdrop-blur-md shrink-0"
|
||||
style={{
|
||||
borderColor: 'var(--header-border)',
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/" className="group flex items-center gap-2.5">
|
||||
<Link to={homeTo} className="group flex items-center gap-2.5">
|
||||
<img src="/favicon.ico" alt="" className="w-8 h-8" />
|
||||
<span className="text-lg font-bold tracking-tight">
|
||||
메이플스토리 유틸리티
|
||||
|
|
@ -71,6 +138,7 @@ export default function Layout() {
|
|||
</Link>
|
||||
<CurrentMenuTitle />
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className={`flex-1 mx-auto w-full max-w-[1400px] ${
|
||||
|
|
|
|||
|
|
@ -1,201 +1,431 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { api } from '../api/client'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'event', label: '이벤트', dataKey: 'event_notice', filterOngoing: true, dateStartKey: 'date_event_start', dateEndKey: 'date_event_end' },
|
||||
{ key: 'cashshop', label: '캐시샵', dataKey: 'cashshop_notice', filterOngoing: true, dateStartKey: 'date_sale_start', dateEndKey: 'date_sale_end' },
|
||||
{ key: 'update', label: '업데이트', dataKey: 'update_notice' },
|
||||
{ key: 'notice', label: '공지', dataKey: 'notice' },
|
||||
]
|
||||
const SECTIONS = {
|
||||
notice: { label: '메이플스토리 공지사항', dataKey: 'notice', pageSize: 5, kind: 'text' },
|
||||
update: { label: '메이플스토리 업데이트', dataKey: 'update_notice', pageSize: 5, kind: 'text' },
|
||||
event: {
|
||||
label: '진행 중인 이벤트',
|
||||
dataKey: 'event_notice',
|
||||
pageSize: 3,
|
||||
kind: 'card',
|
||||
dateStartKey: 'date_event_start',
|
||||
dateEndKey: 'date_event_end',
|
||||
filterOngoing: true,
|
||||
},
|
||||
cashshop: {
|
||||
label: '캐시샵 공지',
|
||||
dataKey: 'cashshop_notice',
|
||||
pageSize: 3,
|
||||
kind: 'card',
|
||||
dateStartKey: 'date_sale_start',
|
||||
dateEndKey: 'date_sale_end',
|
||||
filterOngoing: true,
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 6
|
||||
|
||||
function formatDate(iso) {
|
||||
function fmtMD(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${m}.${day}`
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
function isOngoing(notice, tab) {
|
||||
if (!tab.filterOngoing) return false
|
||||
const endDate = notice[tab.dateEndKey]
|
||||
// 종료일이 있으면 종료일 비교
|
||||
if (endDate) return new Date(endDate) > new Date()
|
||||
// 종료일이 없으면 ongoing_flag로 판단 (캐시샵 상시판매 등)
|
||||
if (notice.ongoing_flag !== undefined) {
|
||||
return notice.ongoing_flag === 'true' || notice.ongoing_flag === true
|
||||
}
|
||||
function fmtYMD(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
function isRecent(iso, days = 3) {
|
||||
if (!iso) return false
|
||||
return (Date.now() - new Date(iso).getTime()) / 86400000 < days
|
||||
}
|
||||
function isOngoing(item, cfg) {
|
||||
if (!cfg.filterOngoing) return true
|
||||
const end = item[cfg.dateEndKey]
|
||||
if (end) return new Date(end) > new Date()
|
||||
if (item.ongoing_flag !== undefined) return item.ongoing_flag === 'true' || item.ongoing_flag === true
|
||||
return false
|
||||
}
|
||||
|
||||
function splitTitle(title) {
|
||||
// "3월 23일 캐시아이템 업데이트 - 메이플스토리 & 진" → ["3월 23일 캐시아이템 업데이트", "메이플스토리 & 진"]
|
||||
const idx = title.indexOf(' - ')
|
||||
if (idx === -1) return { prefix: null, main: title }
|
||||
return {
|
||||
prefix: title.slice(0, idx),
|
||||
main: title.slice(idx + 3),
|
||||
function dayBadge(item, cfg) {
|
||||
const now = Date.now()
|
||||
const start = item[cfg.dateStartKey] ? new Date(item[cfg.dateStartKey]).getTime() : null
|
||||
const end = item[cfg.dateEndKey] ? new Date(item[cfg.dateEndKey]).getTime() : null
|
||||
if (start && start > now) {
|
||||
const d = Math.ceil((start - now) / 86400000)
|
||||
return { label: `시작 ${d}일 전`, tone: 'emerald' }
|
||||
}
|
||||
if (end) {
|
||||
const d = Math.ceil((end - now) / 86400000)
|
||||
if (d <= 0) return null
|
||||
return { label: `종료 ${d}일 전`, tone: 'amber' }
|
||||
}
|
||||
if (item.ongoing_flag === 'true' || item.ongoing_flag === true) {
|
||||
return { label: '상시판매', tone: 'gray' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function NoticeCard({ notice, tab }) {
|
||||
const startDate = tab.dateStartKey ? notice[tab.dateStartKey] : null
|
||||
const endDate = tab.dateEndKey ? notice[tab.dateEndKey] : null
|
||||
const hasDateRange = startDate || endDate
|
||||
/* ─── Text List Section ─────────────────────────────────────── */
|
||||
|
||||
const dateText = hasDateRange
|
||||
? `${formatDate(startDate || notice.date)}${endDate ? ` ~ ${formatDate(endDate)}` : ''}`
|
||||
: (tab.key === 'cashshop' && isOngoing(notice, tab) ? '상시판매' : formatDate(notice.date))
|
||||
function TextListSection({ cfg, items, isMaintenance, isLoading }) {
|
||||
const [page, setPage] = useState(0)
|
||||
const pages = Math.max(1, Math.ceil(items.length / cfg.pageSize))
|
||||
const clamped = Math.min(page, pages - 1)
|
||||
const slice = items.slice(clamped * cfg.pageSize, (clamped + 1) * cfg.pageSize)
|
||||
|
||||
const { prefix, main } = splitTitle(notice.title)
|
||||
return (
|
||||
<section
|
||||
className="rounded-2xl border overflow-hidden flex flex-col"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-4 py-3 border-b"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<h3
|
||||
className="text-sm font-bold"
|
||||
style={{ color: 'var(--text-emphasis)' }}
|
||||
>
|
||||
{cfg.label}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="relative overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div
|
||||
className="p-8 text-center text-sm"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : isMaintenance ? (
|
||||
<div className="p-8 text-center">
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--maintenance-text)' }}
|
||||
>
|
||||
넥슨 Open API 점검중
|
||||
</div>
|
||||
</div>
|
||||
) : slice.length === 0 ? (
|
||||
<div
|
||||
className="p-8 text-center text-sm"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
등록된 항목이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.ul
|
||||
key={`page-${clamped}`}
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -24 }}
|
||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="divide-y"
|
||||
style={{ '--tw-divide-opacity': 1, borderColor: 'var(--row-divider)' }}
|
||||
>
|
||||
{slice.map((it) => (
|
||||
<li
|
||||
key={it.notice_id}
|
||||
className="flex items-center gap-2 border-t first:border-t-0"
|
||||
style={{ borderColor: 'var(--row-divider)' }}
|
||||
>
|
||||
<a
|
||||
href={it.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 min-w-0 flex items-center gap-2 px-3.5 py-2 transition hover:bg-[var(--row-hover-bg)]"
|
||||
>
|
||||
{isRecent(it.date) && (
|
||||
<span
|
||||
className="shrink-0 inline-flex items-center justify-center w-4 h-4 rounded-full text-[9px] font-bold"
|
||||
style={{ background: 'var(--accent)', color: 'var(--badge-text)' }}
|
||||
>
|
||||
N
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex-1 min-w-0 text-[13px] truncate transition-colors hover:text-[var(--accent-hover-text)]"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{it.title}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-[11px] tabular-nums"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
{fmtYMD(it.date)}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
{pages > 1 && (
|
||||
<div
|
||||
className="flex items-center justify-between border-t px-4 py-3 text-sm"
|
||||
style={{ borderColor: 'var(--panel-border)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={clamped === 0}
|
||||
className="inline-flex items-center gap-1.5 transition hover:text-[var(--text-strong)] disabled:opacity-30 disabled:hover:text-[var(--text-muted)]"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7.5 3L4.5 6L7.5 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
이전
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{Array.from({ length: pages }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setPage(i)}
|
||||
aria-label={`${i + 1}페이지`}
|
||||
className="w-2 h-2 rounded-full transition"
|
||||
style={{
|
||||
background: i === clamped ? 'var(--accent)' : 'var(--dot-inactive)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (i !== clamped) e.currentTarget.style.background = 'var(--dot-inactive-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (i !== clamped) e.currentTarget.style.background = 'var(--dot-inactive)'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}
|
||||
disabled={clamped >= pages - 1}
|
||||
className="inline-flex items-center gap-1.5 transition hover:text-[var(--text-strong)] disabled:opacity-30 disabled:hover:text-[var(--text-muted)]"
|
||||
>
|
||||
다음
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Carousel Section (image cards) ────────────────────────── */
|
||||
|
||||
function CardItem({ item, cfg }) {
|
||||
const badge = dayBadge(item, cfg)
|
||||
const start = item[cfg.dateStartKey]
|
||||
const end = item[cfg.dateEndKey]
|
||||
const startMD = fmtMD(start || item.date)
|
||||
const endMD = fmtMD(end || item.date)
|
||||
const dateText = (item.ongoing_flag === 'true' || item.ongoing_flag === true)
|
||||
? '상시판매'
|
||||
: start || end
|
||||
? (startMD === endMD ? startMD : `${startMD} ~ ${endMD}`)
|
||||
: fmtYMD(item.date)
|
||||
const badgeBg = {
|
||||
emerald: 'var(--badge-emerald-bg)',
|
||||
amber: 'var(--badge-amber-bg)',
|
||||
gray: 'var(--badge-gray-bg)',
|
||||
}[badge?.tone]
|
||||
|
||||
return (
|
||||
<a
|
||||
href={notice.url}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex gap-3 rounded-lg border border-white/5 bg-gray-950/40 p-2 hover:border-white/15 hover:bg-gray-950/70 transition"
|
||||
className="group relative block rounded-xl overflow-hidden border"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
{notice.thumbnail_url && (
|
||||
<div className="shrink-0 w-20 h-20 rounded-md bg-gray-900 overflow-hidden">
|
||||
<div
|
||||
className="aspect-[2/1] overflow-hidden"
|
||||
style={{ background: 'var(--thumb-bg)' }}
|
||||
>
|
||||
{item.thumbnail_url ? (
|
||||
<img
|
||||
src={notice.thumbnail_url}
|
||||
src={item.thumbnail_url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition duration-500"
|
||||
className="w-full h-full object-cover group-hover:scale-[1.03] transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center text-4xl"
|
||||
style={{ color: 'var(--thumb-placeholder)' }}
|
||||
>
|
||||
📢
|
||||
</div>
|
||||
)}
|
||||
{badge && (
|
||||
<span
|
||||
className="absolute top-2 right-2 px-2 py-0.5 rounded-full text-[11px] font-medium"
|
||||
style={{ background: badgeBg, color: 'var(--badge-text)' }}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 space-y-1">
|
||||
<div
|
||||
className="text-sm font-medium line-clamp-1 transition-colors group-hover:text-[var(--accent-hover-text)]"
|
||||
style={{ color: 'var(--text-emphasis)' }}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between py-1 gap-1.5">
|
||||
<div className="min-w-0 space-y-1">
|
||||
{prefix && (
|
||||
<p className="text-xs text-gray-500 truncate leading-tight">{prefix}</p>
|
||||
)}
|
||||
<p className="text-sm font-medium text-gray-200 group-hover:text-emerald-300 transition line-clamp-2 leading-snug">
|
||||
{main}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<span>📅</span>
|
||||
<span>{dateText}</span>
|
||||
<div
|
||||
className="text-xs tabular-nums"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
{dateText}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NoticeWidget() {
|
||||
const [activeTab, setActiveTab] = useState('event')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const tab = TABS.find((t) => t.key === activeTab)
|
||||
function CarouselSection({ cfg, items, isMaintenance, isLoading }) {
|
||||
const [page, setPage] = useState(0)
|
||||
const pages = Math.max(1, Math.ceil(items.length / cfg.pageSize))
|
||||
const clamped = Math.min(page, pages - 1)
|
||||
const slice = items.slice(clamped * cfg.pageSize, (clamped + 1) * cfg.pageSize)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['notices', activeTab],
|
||||
queryFn: () => api(`/api/notices?type=${activeTab}`),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const list = data?.[tab.dataKey] || []
|
||||
const allItems = tab.filterOngoing
|
||||
? list.filter((n) => isOngoing(n, tab))
|
||||
: list
|
||||
const initialItems = allItems.slice(0, DEFAULT_LIMIT)
|
||||
const extraItems = allItems.slice(DEFAULT_LIMIT)
|
||||
const hasMore = extraItems.length > 0
|
||||
|
||||
const handleTabChange = (key) => {
|
||||
setActiveTab(key)
|
||||
setExpanded(false)
|
||||
}
|
||||
const navBtn = "w-7 h-7 rounded-md border flex items-center justify-center border-[var(--btn-border)] bg-[var(--btn-bg)] hover:bg-[var(--btn-bg-hover)] hover:border-[var(--btn-border-hover)] disabled:opacity-30 disabled:hover:bg-[var(--btn-bg)] disabled:hover:border-[var(--btn-border)]"
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 overflow-hidden">
|
||||
{/* 헤더 + 탭 */}
|
||||
<div className="flex items-center justify-between border-b border-white/5 px-5 py-3 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">📢</span>
|
||||
<h2 className="font-semibold">메이플 공지</h2>
|
||||
{tab.filterOngoing && allItems.length > 0 && (
|
||||
<span className="text-xs text-gray-500">진행중 {allItems.length}건</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{TABS.map((t) => (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3
|
||||
className="text-base font-medium"
|
||||
style={{ color: 'var(--text-emphasis)' }}
|
||||
>
|
||||
{cfg.label}
|
||||
</h3>
|
||||
{pages > 1 && (
|
||||
<div
|
||||
className="flex items-center gap-3 text-sm"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => handleTabChange(t.key)}
|
||||
className={`text-xs px-2.5 py-1 rounded-md transition ${
|
||||
activeTab === t.key
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-gray-500 hover:text-gray-300 hover:bg-white/5'
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={clamped === 0}
|
||||
className={navBtn}
|
||||
aria-label="이전"
|
||||
>
|
||||
{t.label}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7.5 3L4.5 6L7.5 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="tabular-nums min-w-[48px] text-center">
|
||||
<span style={{ color: 'var(--text-emphasis)' }}>{clamped + 1}</span>
|
||||
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>/</span>
|
||||
{pages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}
|
||||
disabled={clamped >= pages - 1}
|
||||
className={navBtn}
|
||||
aria-label="다음"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="p-3">
|
||||
<div className="relative overflow-x-clip pb-2">
|
||||
{isLoading ? (
|
||||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-24 rounded-lg bg-white/[0.02] animate-pulse" />
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: cfg.pageSize }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-[2/1] rounded-xl animate-pulse"
|
||||
style={{ background: 'var(--skeleton-bg)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : initialItems.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
{tab.filterOngoing ? `진행중인 ${tab.label}이 없습니다` : `등록된 ${tab.label}이 없습니다`}
|
||||
) : isMaintenance ? (
|
||||
<div
|
||||
className="py-10 rounded-xl border text-center"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--maintenance-text)' }}
|
||||
>
|
||||
넥슨 Open API 점검중
|
||||
</div>
|
||||
</div>
|
||||
) : slice.length === 0 ? (
|
||||
<div
|
||||
className="py-10 rounded-xl border border-dashed text-center text-sm"
|
||||
style={{ borderColor: 'var(--dashed-border)', color: 'var(--text-dim)' }}
|
||||
>
|
||||
{cfg.filterOngoing ? `진행중인 ${cfg.label}이 없습니다` : `등록된 ${cfg.label}이 없습니다`}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{initialItems.map((notice) => (
|
||||
<NoticeCard key={notice.notice_id} notice={notice} tab={tab} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 펼쳐지는 영역 - grid-template-rows 트릭으로 부드럽게 애니메이션 */}
|
||||
{hasMore && (
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||
style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 pt-2">
|
||||
{extraItems.map((notice) => (
|
||||
<NoticeCard key={notice.notice_id} notice={notice} tab={tab} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 더보기 / 접기 */}
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-3 w-full rounded-lg border border-white/5 bg-gray-950/30 hover:bg-gray-950/60 hover:border-white/10 py-2 text-xs text-gray-400 hover:text-gray-200 transition flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<span>{expanded ? '접기' : `더보기 (${extraItems.length}건)`}</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className={`transition-transform duration-300 ${expanded ? 'rotate-180' : ''}`}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`cpage-${clamped}`}
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -30 }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{slice.map((it) => <CardItem key={it.notice_id} item={it} cfg={cfg} />)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Root ──────────────────────────────────────────────────── */
|
||||
|
||||
export default function NoticeWidget() {
|
||||
const queries = useQueries({
|
||||
queries: Object.keys(SECTIONS).map((key) => ({
|
||||
queryKey: ['notices', key],
|
||||
queryFn: () => api(`/api/notices?type=${key}`),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: (n, err) => (err?.maintenance ? false : n < 1),
|
||||
})),
|
||||
})
|
||||
|
||||
const byKey = Object.keys(SECTIONS).reduce((acc, key, i) => {
|
||||
const q = queries[i]
|
||||
const cfg = SECTIONS[key]
|
||||
const list = q.data?.[cfg.dataKey] || []
|
||||
const items = cfg.filterOngoing ? list.filter((n) => isOngoing(n, cfg)) : list
|
||||
acc[key] = { items, isLoading: q.isLoading, isMaintenance: !!q.error?.maintenance }
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<TextListSection cfg={SECTIONS.notice} {...byKey.notice} />
|
||||
<TextListSection cfg={SECTIONS.update} {...byKey.update} />
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<CarouselSection cfg={SECTIONS.event} {...byKey.event} />
|
||||
</div>
|
||||
<CarouselSection cfg={SECTIONS.cashshop} {...byKey.cashshop} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +1,144 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
/**
|
||||
* 커스텀 드롭다운 셀렉트
|
||||
* <Select value={x} onChange={...} options={[{value, label}]} />
|
||||
* 커스텀 드롭다운 셀렉트 (포털로 렌더링 → 부모 overflow:hidden에도 잘림 없음)
|
||||
*/
|
||||
export default function Select({ value, onChange, options, disabled, className = '', placeholder = '선택', align = 'left' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const [flipUp, setFlipUp] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||
const buttonRef = useRef(null)
|
||||
const popupRef = useRef(null)
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!buttonRef.current) return
|
||||
const rect = buttonRef.current.getBoundingClientRect()
|
||||
const estHeight = Math.min(options.length * 44 + 8, 240)
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
const flip = spaceBelow < estHeight && spaceAbove > spaceBelow
|
||||
setFlipUp(flip)
|
||||
setPos({
|
||||
top: flip ? rect.top : rect.bottom,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
bottomOffset: flip ? window.innerHeight - rect.top : 0,
|
||||
})
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open) updatePosition()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e) => {
|
||||
if (!ref.current?.contains(e.target)) setOpen(false)
|
||||
const onDown = (e) => {
|
||||
if (buttonRef.current?.contains(e.target)) return
|
||||
if (popupRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
const onScroll = () => updatePosition()
|
||||
document.addEventListener('mousedown', onDown)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', onScroll)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', onScroll)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
const selected = options.find((o) => o.value === value)
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`relative ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setOpen((v) => !v)}
|
||||
className={`w-full flex items-center justify-between gap-2 rounded-lg border bg-gray-950 px-3 py-2 text-sm transition outline-none ${
|
||||
open ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span className={selected ? '' : 'text-gray-500'}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<svg className={`w-3.5 h-3.5 text-gray-500 transition ${open ? 'rotate-180' : ''}`} viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`absolute top-full mt-1 z-20 min-w-full rounded-lg border border-white/10 bg-gray-900 shadow-xl overflow-hidden origin-top ${
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
}`}
|
||||
>
|
||||
<div className="max-h-60 overflow-y-auto py-1">
|
||||
{options.map((opt) => (
|
||||
const popup = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={popupRef}
|
||||
initial={{ opacity: 0, y: flipUp ? 6 : -6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: flipUp ? 6 : -6, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`fixed z-[100] rounded-lg border overflow-hidden ${
|
||||
flipUp ? 'origin-bottom' : 'origin-top'
|
||||
}`}
|
||||
style={{
|
||||
background: 'var(--popup-bg)',
|
||||
borderColor: 'var(--popup-border)',
|
||||
boxShadow: 'var(--popup-shadow)',
|
||||
color: 'var(--text-strong)',
|
||||
...(flipUp
|
||||
? { bottom: pos.bottomOffset + 4, left: pos.left, minWidth: pos.width }
|
||||
: { top: pos.top + 4, left: pos.left, minWidth: pos.width }
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="max-h-60 overflow-y-auto py-1">
|
||||
{options.map((opt) => {
|
||||
const isActive = opt.value === value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => { onChange(opt.value); setOpen(false) }}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm transition flex items-center gap-2 ${
|
||||
opt.value === value
|
||||
? 'bg-emerald-500/10 text-emerald-300'
|
||||
: 'hover:bg-white/5'
|
||||
}`}
|
||||
className="w-full text-left px-3 py-2.5 text-sm flex items-center gap-2"
|
||||
style={isActive ? {
|
||||
background: 'var(--option-selected-bg)',
|
||||
color: 'var(--option-selected-text)',
|
||||
} : undefined}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.background = 'var(--row-hover-bg)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) e.currentTarget.style.background = ''
|
||||
}}
|
||||
>
|
||||
{opt.value === value && (
|
||||
{isActive && (
|
||||
<svg className="w-3 h-3 shrink-0" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2.5 6L5 8.5L9.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
<span className={opt.value !== value ? 'pl-5' : ''}>{opt.label}</span>
|
||||
<span className={!isActive ? 'pl-5' : ''}>{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setOpen((v) => !v)}
|
||||
className={`w-full flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm outline-none ${
|
||||
disabled ? 'opacity-50 !cursor-default' : ''
|
||||
}`}
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: open ? 'var(--input-border-focus)' : 'var(--input-border)',
|
||||
color: 'var(--text-strong)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: selected ? 'var(--text-strong)' : 'var(--input-placeholder)' }}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 transition ${open ? 'rotate-180' : ''}`}
|
||||
style={{ color: 'var(--input-icon)' }}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{createPortal(popup, document.body)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,8 +98,11 @@ export default function Tooltip({ text, children, placement = 'top', delay = 200
|
|||
zIndex: 9999,
|
||||
opacity: coords ? 1 : 0,
|
||||
transition: 'opacity 120ms ease-out',
|
||||
background: 'var(--tooltip-bg)',
|
||||
color: 'var(--tooltip-text)',
|
||||
borderColor: 'var(--tooltip-border)',
|
||||
}}
|
||||
className="pointer-events-none px-2 py-1 rounded-md bg-gray-900 border border-white/10 text-xs text-gray-200 shadow-lg whitespace-nowrap"
|
||||
className="pointer-events-none px-2 py-1 rounded-md border text-xs shadow-lg whitespace-nowrap"
|
||||
>
|
||||
{text}
|
||||
</div>,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { api } from '../../api/client'
|
|||
|
||||
function MenuCard({ menu }) {
|
||||
const navigate = useNavigate()
|
||||
// 메뉴 url에서 slug 추출 (/boss-crystal → boss-crystal)
|
||||
const slug = (menu.url || '').replace(/^\/+/, '').split('/')[0]
|
||||
const adminPath = slug ? `/admin/${slug}` : `/admin/menus/${menu.id}`
|
||||
|
||||
|
|
@ -17,14 +16,16 @@ function MenuCard({ menu }) {
|
|||
return (
|
||||
<Link
|
||||
to={adminPath}
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-5 hover:border-emerald-500/30 hover:from-emerald-500/5 hover:to-cyan-500/5 transition-all duration-300"
|
||||
className="group relative rounded-2xl border p-5 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))',
|
||||
boxShadow: 'var(--card-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute -top-12 -right-12 w-32 h-32 rounded-full bg-emerald-500/0 group-hover:bg-emerald-500/10 blur-2xl transition-all duration-500" />
|
||||
|
||||
{/* 톱니바퀴 - 메뉴 정보 편집 */}
|
||||
<button
|
||||
onClick={handleEditClick}
|
||||
className="absolute top-3 right-3 w-8 h-8 rounded-lg border border-white/5 hover:border-white/20 hover:bg-white/5 text-gray-500 hover:text-gray-300 flex items-center justify-center text-sm transition opacity-0 group-hover:opacity-100 z-10"
|
||||
className="absolute top-3 right-3 w-8 h-8 rounded-lg border flex items-center justify-center text-sm opacity-0 group-hover:opacity-100 transition-opacity z-10 border-[var(--btn-border)] bg-[var(--btn-bg)] hover:bg-[var(--btn-bg-hover)]"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
title="메뉴 정보 편집"
|
||||
aria-label="메뉴 정보 편집"
|
||||
>
|
||||
|
|
@ -32,13 +33,20 @@ function MenuCard({ menu }) {
|
|||
</button>
|
||||
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden group-hover:scale-110 group-hover:border-emerald-500/30 transition-all duration-300">
|
||||
<div
|
||||
className="shrink-0 w-12 h-12 rounded-xl border flex items-center justify-center overflow-hidden border-[var(--icon-box-border)]"
|
||||
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
|
||||
>
|
||||
<img src={menu.image?.url || '/default.png'} alt={menu.title} className="w-9 h-9 object-contain" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pr-8">
|
||||
<h3 className="font-semibold text-white group-hover:text-emerald-300 transition truncate">{menu.title}</h3>
|
||||
<p className="text-sm text-gray-400 mt-1 leading-relaxed truncate">{menu.description}</p>
|
||||
<p className="text-xs text-gray-600 mt-1 font-mono truncate">{menu.url}</p>
|
||||
<h3 className="font-medium truncate">{menu.title}</h3>
|
||||
<p
|
||||
className="text-sm mt-1 leading-relaxed line-clamp-2"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{menu.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
@ -49,12 +57,16 @@ function AddCard({ to, icon, label }) {
|
|||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-white/10 hover:border-emerald-500/40 bg-white/[0.02] hover:bg-emerald-500/5 p-5 min-h-[112px] transition-all"
|
||||
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed p-5 min-h-[112px] transition-transform duration-300 hover:scale-[1.02] border-[var(--dashed-border)]"
|
||||
style={{ background: 'var(--skeleton-bg)' }}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full border border-white/10 group-hover:border-emerald-500/40 flex items-center justify-center text-gray-500 group-hover:text-emerald-400 transition">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full border flex items-center justify-center border-[var(--dashed-border)]"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 group-hover:text-emerald-300 transition">{label}</span>
|
||||
<span className="text-sm" style={{ color: 'var(--text-dim)' }}>{label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
@ -67,20 +79,29 @@ export default function AdminHome() {
|
|||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-8 max-w-5xl mx-auto pt-6">
|
||||
{/* 메뉴 섹션 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">기능 관리</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">메뉴 항목을 추가하거나 관리합니다</p>
|
||||
<h2 className="text-lg font-medium">기능 관리</h2>
|
||||
<p
|
||||
className="text-sm mt-0.5"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
메뉴 항목을 추가하거나 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-28 rounded-2xl bg-white/[0.02] animate-pulse" />
|
||||
<div
|
||||
key={i}
|
||||
className="h-28 rounded-2xl animate-pulse"
|
||||
style={{ background: 'var(--skeleton-bg)' }}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -96,27 +117,41 @@ export default function AdminHome() {
|
|||
{/* 자원 관리 섹션 */}
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">자원 관리</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">공용 이미지 등 사이트 자원을 관리합니다</p>
|
||||
<h2 className="text-lg font-medium">자원 관리</h2>
|
||||
<p
|
||||
className="text-sm mt-0.5"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
공용 이미지 등 사이트 자원을 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Link
|
||||
to="/admin/images"
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-5 hover:border-cyan-500/30 hover:from-cyan-500/5 hover:to-blue-500/5 transition-all duration-300"
|
||||
className="group relative rounded-2xl border p-5 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))',
|
||||
boxShadow: 'var(--card-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute -top-12 -right-12 w-32 h-32 rounded-full bg-cyan-500/0 group-hover:bg-cyan-500/10 blur-2xl transition-all duration-500" />
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center text-2xl group-hover:scale-110 group-hover:border-cyan-500/30 transition-all duration-300">
|
||||
<div
|
||||
className="shrink-0 w-12 h-12 rounded-xl border flex items-center justify-center text-2xl border-[var(--icon-box-border)]"
|
||||
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
|
||||
>
|
||||
🖼️
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold group-hover:text-cyan-300 transition">이미지 관리</h3>
|
||||
<p className="text-sm text-gray-400 mt-1 leading-relaxed">공용 이미지 업로드 및 관리</p>
|
||||
</div>
|
||||
<div className="text-gray-700 group-hover:text-cyan-400 group-hover:translate-x-1 transition-all duration-300">
|
||||
→
|
||||
<h3 className="font-medium">이미지 관리</h3>
|
||||
<p
|
||||
className="text-sm mt-1 leading-relaxed"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
공용 이미지 업로드 및 관리
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-dim)' }}>→</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -127,7 +162,8 @@ export default function AdminHome() {
|
|||
<div className="pt-4 text-center">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-xs text-gray-600 hover:text-red-400 transition"
|
||||
className="text-xs transition-colors hover:text-red-500"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
관리자 로그아웃
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export default function AdminMenuForm() {
|
|||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{isEdit ? '메뉴 항목 편집' : '메뉴 항목 추가'}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">홈 화면에 표시되는 카드의 정보를 설정합니다</p>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,23 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery, useQueries } from '@tanstack/react-query'
|
||||
import { api } from '../../api/client'
|
||||
import { useLayout } from '../../components/Layout'
|
||||
import CharacterPanel from './user/CharacterPanel'
|
||||
import BossSelector from './user/BossSelector'
|
||||
import { useBossStore } from './store'
|
||||
|
||||
const STORAGE_CHARS = 'maple-bc-characters'
|
||||
const STORAGE_SELECTIONS = 'maple-bc-selections'
|
||||
const MAX_PER_CHARACTER = 12
|
||||
|
||||
export default function BossCrystal() {
|
||||
const [characters, setCharacters] = useState(() => {
|
||||
const saved = localStorage.getItem(STORAGE_CHARS)
|
||||
return saved ? JSON.parse(saved) : []
|
||||
})
|
||||
const [selectedChar, setSelectedChar] = useState(() => {
|
||||
const saved = localStorage.getItem(STORAGE_CHARS)
|
||||
const list = saved ? JSON.parse(saved) : []
|
||||
return list[0]?.character_name || null
|
||||
})
|
||||
const [allSelections, setAllSelections] = useState(() => {
|
||||
const saved = localStorage.getItem(STORAGE_SELECTIONS)
|
||||
return saved ? JSON.parse(saved) : {}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_CHARS, JSON.stringify(characters))
|
||||
}, [characters])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_SELECTIONS, JSON.stringify(allSelections))
|
||||
}, [allSelections])
|
||||
const characters = useBossStore((s) => s.characters)
|
||||
const selectedChar = useBossStore((s) => s.selectedChar)
|
||||
const selections = useBossStore((s) => s.selections)
|
||||
const addCharacter = useBossStore((s) => s.addCharacter)
|
||||
const removeCharacter = useBossStore((s) => s.removeCharacter)
|
||||
const selectCharacter = useBossStore((s) => s.selectCharacter)
|
||||
const reorderCharacters = useBossStore((s) => s.reorderCharacters)
|
||||
const setBossSelection = useBossStore((s) => s.setBossSelection)
|
||||
const updateCharacter = useBossStore((s) => s.updateCharacter)
|
||||
|
||||
// 풀스크린 모드 (푸터 숨김 + 내부 스크롤)
|
||||
const { setFullscreen } = useLayout()
|
||||
|
|
@ -44,71 +31,79 @@ export default function BossCrystal() {
|
|||
queryFn: () => api('/api/boss-crystal/bosses').catch(() => []),
|
||||
})
|
||||
|
||||
const handleAddCharacter = (char) => {
|
||||
setCharacters((prev) => [...prev, char])
|
||||
setSelectedChar(char.character_name)
|
||||
}
|
||||
// 저장된 캐릭터의 기본 정보 새로고침
|
||||
const charRefreshQueries = useQueries({
|
||||
queries: characters.map((c) => ({
|
||||
queryKey: ['character', 'basic', c.character_name],
|
||||
queryFn: () => api(`/api/character/search?name=${encodeURIComponent(c.character_name)}`),
|
||||
enabled: !!c.character_name,
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
})),
|
||||
})
|
||||
|
||||
const handleRemoveCharacter = (name) => {
|
||||
setCharacters((prev) => {
|
||||
const next = prev.filter((c) => c.character_name !== name)
|
||||
if (selectedChar === name) {
|
||||
setSelectedChar(next[0]?.character_name || null)
|
||||
useEffect(() => {
|
||||
characters.forEach((c, i) => {
|
||||
const d = charRefreshQueries[i]?.data
|
||||
if (!d) return
|
||||
if (d.character_image !== c.character_image || d.character_level !== c.character_level || d.job_name !== c.job_name) {
|
||||
updateCharacter(c.character_name, {
|
||||
character_image: d.character_image,
|
||||
character_level: d.character_level,
|
||||
job_name: d.job_name,
|
||||
world_name: d.world_name,
|
||||
ocid: d.ocid,
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
setAllSelections((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[name]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleReorderCharacters = (next) => {
|
||||
setCharacters(next)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [charRefreshQueries.map((q) => q.dataUpdatedAt).join(',')])
|
||||
|
||||
const handleBossChange = (bossId, sel) => {
|
||||
if (!selectedChar) return
|
||||
setAllSelections((prev) => {
|
||||
const charSel = { ...(prev[selectedChar] || {}) }
|
||||
if (sel === null) {
|
||||
delete charSel[bossId]
|
||||
} else {
|
||||
charSel[bossId] = sel
|
||||
}
|
||||
return { ...prev, [selectedChar]: charSel }
|
||||
})
|
||||
setBossSelection(selectedChar, bossId, sel)
|
||||
}
|
||||
|
||||
const currentSelections = selectedChar ? (allSelections[selectedChar] || {}) : {}
|
||||
const currentSelections = selectedChar ? (selections[selectedChar] || {}) : {}
|
||||
const currentSelectedCount = Object.values(currentSelections).filter(Boolean).length
|
||||
const isMaxReached = currentSelectedCount >= MAX_PER_CHARACTER
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-16 text-center">
|
||||
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin mx-auto" />
|
||||
<div
|
||||
className="rounded-2xl border p-16 text-center"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="w-6 h-6 border-2 border-t-transparent rounded-full animate-spin mx-auto" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-[420px_1fr] h-full min-h-0">
|
||||
{/* 좌측: 캐릭터 + 결과 통합 (총 수익/추가 고정 + 목록 스크롤) */}
|
||||
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-4 min-h-0 max-h-full self-start overflow-hidden flex flex-col">
|
||||
<div
|
||||
className="rounded-2xl border p-4 min-h-0 max-h-full self-start overflow-hidden flex flex-col"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<CharacterPanel
|
||||
characters={characters}
|
||||
selectedName={selectedChar}
|
||||
allSelections={allSelections}
|
||||
allSelections={selections}
|
||||
bosses={bosses}
|
||||
onSelect={setSelectedChar}
|
||||
onAdd={handleAddCharacter}
|
||||
onRemove={handleRemoveCharacter}
|
||||
onReorder={handleReorderCharacters}
|
||||
onSelect={selectCharacter}
|
||||
onAdd={addCharacter}
|
||||
onRemove={removeCharacter}
|
||||
onReorder={reorderCharacters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 우측: 보스 선택 (헤더 고정 + 목록 스크롤) */}
|
||||
<div className="min-h-0">
|
||||
<BossSelector
|
||||
characterName={selectedChar}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ export default function BossForm() {
|
|||
const displayImage = imagePreview || existingImageUrl
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{isEdit ? '보스 편집' : '보스 추가'}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">보스 이름과 난이도별 결정 정보를 입력합니다</p>
|
||||
|
|
|
|||
55
frontend/src/features/boss-crystal/store.js
Normal file
55
frontend/src/features/boss-crystal/store.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
/**
|
||||
* 보스 수익 계산기 상태
|
||||
* characters: [{ character_name, character_image, character_level, job_name, ... }]
|
||||
* selectedChar: 선택된 캐릭터 닉네임
|
||||
* selections: { [character_name]: { [bossId]: { difficulty, party } } }
|
||||
*/
|
||||
export const useBossStore = create(persist(
|
||||
(set) => ({
|
||||
characters: [],
|
||||
selectedChar: null,
|
||||
selections: {},
|
||||
|
||||
setCharacters: (next) => set((s) => ({
|
||||
characters: typeof next === 'function' ? next(s.characters) : next,
|
||||
})),
|
||||
|
||||
addCharacter: (char) => set((s) => {
|
||||
if (s.characters.find((c) => c.character_name === char.character_name)) return s
|
||||
return {
|
||||
characters: [...s.characters, char],
|
||||
selectedChar: char.character_name,
|
||||
}
|
||||
}),
|
||||
|
||||
removeCharacter: (name) => set((s) => {
|
||||
const next = s.characters.filter((c) => c.character_name !== name)
|
||||
const nextSel = { ...s.selections }
|
||||
delete nextSel[name]
|
||||
return {
|
||||
characters: next,
|
||||
selections: nextSel,
|
||||
selectedChar: s.selectedChar === name ? (next[0]?.character_name || null) : s.selectedChar,
|
||||
}
|
||||
}),
|
||||
|
||||
selectCharacter: (name) => set({ selectedChar: name }),
|
||||
|
||||
updateCharacter: (name, patch) => set((s) => ({
|
||||
characters: s.characters.map((c) => (c.character_name === name ? { ...c, ...patch } : c)),
|
||||
})),
|
||||
|
||||
reorderCharacters: (next) => set({ characters: next }),
|
||||
|
||||
setBossSelection: (charName, bossId, sel) => set((s) => {
|
||||
const charSel = { ...(s.selections[charName] || {}) }
|
||||
if (sel === null) delete charSel[bossId]
|
||||
else charSel[bossId] = sel
|
||||
return { selections: { ...s.selections, [charName]: charSel } }
|
||||
}),
|
||||
}),
|
||||
{ name: 'maple-boss-crystal' },
|
||||
))
|
||||
|
|
@ -1,11 +1,19 @@
|
|||
import Select from '../../../components/Select'
|
||||
import Tooltip from '../../../components/Tooltip'
|
||||
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from '../admin/constants'
|
||||
import { DIFFICULTIES, formatMeso } from '../admin/constants'
|
||||
|
||||
const LABEL_EN = { easy: 'EASY', normal: 'NORMAL', hard: 'HARD', chaos: 'CHAOS', extreme: 'EXTREME' }
|
||||
|
||||
export default function BossSelector({ characterName, bosses, selections, onChange, maxReached, selectedCount, maxPerCharacter }) {
|
||||
if (!characterName) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center text-sm text-gray-500">
|
||||
<div
|
||||
className="rounded-2xl border border-dashed p-16 text-center text-sm"
|
||||
style={{
|
||||
borderColor: 'var(--dashed-border)',
|
||||
background: 'var(--skeleton-bg)',
|
||||
color: 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
좌측에서 캐릭터를 선택해주세요
|
||||
</div>
|
||||
)
|
||||
|
|
@ -13,16 +21,37 @@ export default function BossSelector({ characterName, bosses, selections, onChan
|
|||
|
||||
if (bosses.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center text-sm text-gray-500">
|
||||
<div
|
||||
className="rounded-2xl border border-dashed p-16 text-center text-sm"
|
||||
style={{
|
||||
borderColor: 'var(--dashed-border)',
|
||||
background: 'var(--skeleton-bg)',
|
||||
color: 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
등록된 보스가 없습니다
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/5 bg-gray-900/40 overflow-hidden flex flex-col h-full">
|
||||
<div
|
||||
className="rounded-xl border overflow-hidden flex flex-col h-full"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (고정) */}
|
||||
<div className="flex items-center gap-3 px-3 py-3 bg-gray-950/60 border-b border-white/5 text-base font-semibold text-gray-300 shrink-0">
|
||||
<div
|
||||
className="flex items-center gap-3 px-3 py-3 border-b text-base font-medium shrink-0"
|
||||
style={{
|
||||
background: 'var(--surface-2)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
color: 'var(--text-emphasis)',
|
||||
}}
|
||||
>
|
||||
<div className="w-52 shrink-0">보스</div>
|
||||
<div className="flex-1">난이도</div>
|
||||
<div className="w-20 shrink-0 text-center">파티원 수</div>
|
||||
|
|
@ -30,7 +59,7 @@ export default function BossSelector({ characterName, bosses, selections, onChan
|
|||
</div>
|
||||
{/* 목록 (스크롤) */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="divide-y divide-white/5">
|
||||
<div className="divide-y" style={{ '--tw-divide-opacity': 1 }}>
|
||||
{bosses.map((boss) => {
|
||||
const availableDiffs = DIFFICULTIES.filter((d) =>
|
||||
boss.difficulties.some((bd) => bd.difficulty === d.key)
|
||||
|
|
@ -51,13 +80,20 @@ export default function BossSelector({ characterName, bosses, selections, onChan
|
|||
return (
|
||||
<div
|
||||
key={boss.id}
|
||||
className={`flex items-center gap-3 px-3 py-3 transition ${
|
||||
disabled ? 'opacity-30 pointer-events-none' : ''
|
||||
className={`flex items-center gap-3 px-3 py-3 border-t first:border-t-0 ${
|
||||
disabled ? 'pointer-events-none' : ''
|
||||
}`}
|
||||
style={{
|
||||
borderColor: 'var(--panel-border)',
|
||||
opacity: disabled ? 'var(--disabled-opacity)' : 1,
|
||||
}}
|
||||
>
|
||||
{/* 보스 이미지 + 이름 */}
|
||||
<div className="flex items-center gap-2.5 w-52 shrink-0">
|
||||
<div className="shrink-0 w-11 h-11 rounded-lg bg-gray-900 overflow-hidden">
|
||||
<div
|
||||
className="shrink-0 w-11 h-11 rounded-lg overflow-hidden"
|
||||
style={{ background: 'var(--surface-nested)' }}
|
||||
>
|
||||
<img src={boss.image_url || '/default.png'} alt={boss.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<span className="text-base font-medium leading-tight whitespace-nowrap overflow-hidden text-ellipsis">{boss.name}</span>
|
||||
|
|
@ -67,24 +103,30 @@ export default function BossSelector({ characterName, bosses, selections, onChan
|
|||
<div className="flex-1 flex items-center gap-2 flex-nowrap min-w-0">
|
||||
{availableDiffs.map((d) => {
|
||||
const active = sel?.difficulty === d.key
|
||||
const hasVisibleBorder = d.colors.border !== d.colors.bg
|
||||
const borderColor = hasVisibleBorder ? d.colors.border : 'rgba(0, 0, 0, 0.55)'
|
||||
const style = {
|
||||
background: d.colors.bg,
|
||||
borderColor,
|
||||
borderWidth: '1.5px',
|
||||
color: d.colors.text,
|
||||
filter: active ? 'none' : 'var(--inactive-filter)',
|
||||
}
|
||||
return (
|
||||
<Tooltip key={d.key} text={d.label}>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.blur()
|
||||
if (active) {
|
||||
onChange(boss.id, null)
|
||||
} else {
|
||||
onChange(boss.id, { difficulty: d.key, party: partyN })
|
||||
}
|
||||
}}
|
||||
className={`shrink-0 transition focus:outline-none ${active ? 'opacity-100 scale-105' : 'opacity-40 hover:opacity-70'}`}
|
||||
>
|
||||
<img src={getDifficultyImageUrl(d.key)} alt={d.label} className="h-5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
key={d.key}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.blur()
|
||||
if (active) onChange(boss.id, null)
|
||||
else onChange(boss.id, { difficulty: d.key, party: partyN })
|
||||
}}
|
||||
style={style}
|
||||
className="shrink-0 rounded-full border-solid px-4 h-7 text-xs font-bold tracking-wider transition focus:outline-none"
|
||||
>
|
||||
{LABEL_EN[d.key] || d.key.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -99,12 +141,20 @@ export default function BossSelector({ characterName, bosses, selections, onChan
|
|||
align="right"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-xs text-gray-700 text-center">-</div>
|
||||
<div
|
||||
className="text-xs text-center"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
-
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 수익 */}
|
||||
<div className={`w-32 shrink-0 text-right text-sm font-medium tabular-nums ${sel ? 'text-emerald-300' : 'text-gray-700'}`}>
|
||||
<div
|
||||
className="w-32 shrink-0 text-right text-sm font-medium tabular-nums"
|
||||
style={{ color: sel ? 'var(--accent-bright)' : 'var(--text-dim)' }}
|
||||
>
|
||||
{sel ? formatMeso(revenue) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Reorder, useDragControls } from 'framer-motion'
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
||||
import { api } from '../../../api/client'
|
||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||
import Tooltip from '../../../components/Tooltip'
|
||||
|
|
@ -11,6 +12,7 @@ const MAX_PER_CHARACTER = 12
|
|||
const MAX_PER_ACCOUNT = 90
|
||||
|
||||
function CharacterContent({ char, selections, bosses }) {
|
||||
const bossIndex = new Map(bosses.map((b, i) => [b.id, i]))
|
||||
const selectedBosses = Object.entries(selections || {})
|
||||
.filter(([, sel]) => sel)
|
||||
.map(([bossId, sel]) => {
|
||||
|
|
@ -25,9 +27,12 @@ function CharacterContent({ char, selections, bosses }) {
|
|||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.revenue - a.revenue)
|
||||
|
||||
const visibleBosses = selectedBosses.slice(0, MAX_PER_CHARACTER)
|
||||
// 12개 상한은 수익 높은 순으로 취한 뒤, 표시는 보스 목록 순서대로 정렬
|
||||
const topByRevenue = [...selectedBosses].sort((a, b) => b.revenue - a.revenue).slice(0, MAX_PER_CHARACTER)
|
||||
const visibleBosses = topByRevenue.sort(
|
||||
(a, b) => (bossIndex.get(a.boss.id) ?? 0) - (bossIndex.get(b.boss.id) ?? 0)
|
||||
)
|
||||
const totalRevenue = visibleBosses.reduce((s, x) => s + x.revenue, 0)
|
||||
const count = selectedBosses.length
|
||||
|
||||
|
|
@ -44,14 +49,16 @@ function CharacterContent({ char, selections, bosses }) {
|
|||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-700 text-4xl">?</span>
|
||||
<span className="text-4xl" style={{ color: 'var(--text-dim)' }}>?</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<span className="text-base font-semibold truncate">{char.character_name}</span>
|
||||
<span className="text-xs text-gray-500 truncate">Lv.{char.character_level} · {char.job_name}</span>
|
||||
<span className="text-xs truncate" style={{ color: 'var(--text-dim)' }}>
|
||||
Lv.{char.character_level} · {char.job_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{visibleBosses.length > 0 ? (
|
||||
|
|
@ -61,10 +68,16 @@ function CharacterContent({ char, selections, bosses }) {
|
|||
return (
|
||||
<Tooltip
|
||||
key={item.boss.id}
|
||||
text={`${item.boss.name} ${diff?.label || ''} · ${formatMeso(item.revenue)}`}
|
||||
text={`${diff?.label || ''} ${item.boss.name} · ${formatMeso(item.revenue)}`}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<div className="aspect-square rounded bg-gray-900 overflow-hidden border border-white/5">
|
||||
<div
|
||||
className="aspect-square rounded overflow-hidden border"
|
||||
style={{
|
||||
background: 'var(--surface-nested)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
|
|
@ -81,17 +94,38 @@ function CharacterContent({ char, selections, bosses }) {
|
|||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-600 italic h-[58px] flex items-center">보스 미선택</div>
|
||||
<div
|
||||
className="text-xs italic h-[58px] flex items-center"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
보스 미선택
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-white/5 pt-2">
|
||||
<div
|
||||
className="flex items-center justify-between border-t pt-2"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<div className="flex items-baseline gap-1 tabular-nums">
|
||||
<span className={`text-base font-bold ${count > 0 ? 'text-amber-300' : 'text-gray-600'}`}>{count}</span>
|
||||
<span className="text-base font-bold text-amber-300/40">/ {MAX_PER_CHARACTER}</span>
|
||||
<span
|
||||
className="text-base font-bold"
|
||||
style={{ color: count > 0 ? 'var(--warning-text-bright)' : 'var(--text-dim)' }}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
<span
|
||||
className="text-base font-bold"
|
||||
style={{ color: count > 0 ? 'var(--warning-text-dim)' : 'var(--text-dim)' }}
|
||||
>
|
||||
/ {MAX_PER_CHARACTER}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-sm font-semibold tabular-nums whitespace-nowrap ${count > 0 ? 'text-emerald-300' : 'text-gray-700'}`}>
|
||||
<div
|
||||
className="text-sm font-semibold tabular-nums whitespace-nowrap"
|
||||
style={{ color: count > 0 ? 'var(--accent-bright)' : 'var(--text-dim)' }}
|
||||
>
|
||||
{count > 0 ? formatMeso(totalRevenue) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -118,17 +152,17 @@ function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemov
|
|||
if (e.target.closest('button')) return
|
||||
onSelect(char.character_name)
|
||||
}}
|
||||
className={`group relative rounded-xl border cursor-pointer select-none ${
|
||||
isSelected
|
||||
? 'border-emerald-500/40 bg-emerald-500/[0.08]'
|
||||
: 'border-white/5 hover:border-white/15 bg-gray-950/40 hover:bg-gray-950/60'
|
||||
}`}
|
||||
className="group relative rounded-xl border cursor-pointer select-none"
|
||||
style={{
|
||||
borderColor: isSelected ? 'var(--selected-border)' : 'var(--panel-border)',
|
||||
background: isSelected ? 'var(--selected-bg)' : 'var(--surface-3)',
|
||||
}}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<div
|
||||
onPointerDown={(e) => { e.preventDefault(); dragControls.start(e) }}
|
||||
className="absolute left-0 top-0 bottom-0 w-8 flex items-center justify-center text-gray-600 hover:text-gray-400 cursor-grab active:cursor-grabbing"
|
||||
style={{ touchAction: 'none' }}
|
||||
className="absolute left-0 top-0 bottom-0 w-8 flex items-center justify-center cursor-grab active:cursor-grabbing"
|
||||
style={{ touchAction: 'none', color: 'var(--text-dim)' }}
|
||||
>
|
||||
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
|
||||
<circle cx="3" cy="3" r="1.2" />
|
||||
|
|
@ -143,7 +177,8 @@ function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemov
|
|||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(char) }}
|
||||
className="absolute top-2 right-2 z-10 w-6 h-6 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition opacity-0 group-hover:opacity-100 flex items-center justify-center text-base"
|
||||
className="absolute top-2 right-2 z-10 w-6 h-6 rounded opacity-0 group-hover:opacity-100 flex items-center justify-center text-base hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)]"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
aria-label="삭제"
|
||||
>
|
||||
×
|
||||
|
|
@ -218,13 +253,20 @@ export default function CharacterPanel({
|
|||
return (
|
||||
<div className="flex flex-col gap-4 min-h-0 flex-1">
|
||||
{/* 총 수익 카드 (고정) */}
|
||||
<div className="rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-500/15 to-emerald-500/5 p-4 space-y-3 shrink-0">
|
||||
<div
|
||||
className="rounded-2xl border p-4 space-y-3 shrink-0"
|
||||
style={{
|
||||
borderColor: 'var(--selected-border)',
|
||||
background: 'var(--selected-bg)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs text-emerald-200/80">총 주간 수익</div>
|
||||
<div className="text-xs" style={{ color: 'var(--accent-bright)' }}>총 주간 수익</div>
|
||||
<div ref={totalContainerRef} className="mt-1 overflow-hidden">
|
||||
<div
|
||||
ref={totalTextRef}
|
||||
className="font-bold text-emerald-300 leading-tight whitespace-nowrap inline-block"
|
||||
className="font-bold leading-tight whitespace-nowrap inline-block"
|
||||
style={{ color: 'var(--accent-bright)' }}
|
||||
>
|
||||
{totalText}
|
||||
</div>
|
||||
|
|
@ -233,25 +275,42 @@ export default function CharacterPanel({
|
|||
|
||||
<div className="grid grid-cols-[1fr_auto] gap-x-3 items-center">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-400">총 결정 개수</div>
|
||||
<div className="h-2 rounded-full bg-gray-900 overflow-hidden">
|
||||
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>총 결정 개수</div>
|
||||
<div
|
||||
className="h-2 rounded-full overflow-hidden"
|
||||
style={{ background: 'var(--progress-track)' }}
|
||||
>
|
||||
<div
|
||||
className={`h-full transition-all ${totalCount > MAX_PER_ACCOUNT ? 'bg-amber-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${usagePct}%` }}
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${usagePct}%`,
|
||||
background: totalCount > MAX_PER_ACCOUNT ? 'var(--progress-amber)' : 'var(--progress-emerald)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 tabular-nums">
|
||||
<span className={`text-2xl font-bold leading-none ${totalCount > MAX_PER_ACCOUNT ? 'text-red-400' : 'text-amber-300'}`}>
|
||||
<span
|
||||
className="text-2xl font-bold leading-none"
|
||||
style={{ color: totalCount > MAX_PER_ACCOUNT ? 'var(--danger-text)' : 'var(--warning-text-bright)' }}
|
||||
>
|
||||
{accountUsage}
|
||||
</span>
|
||||
<span className={`text-2xl font-bold leading-none ${totalCount > MAX_PER_ACCOUNT ? 'text-red-400/40' : 'text-amber-300/40'}`}>
|
||||
<span
|
||||
className="text-2xl font-bold leading-none"
|
||||
style={{
|
||||
color: totalCount > MAX_PER_ACCOUNT ? 'var(--danger-text)' : 'var(--warning-text-dim)',
|
||||
opacity: totalCount > MAX_PER_ACCOUNT ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
/ {MAX_PER_ACCOUNT}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{totalCount > MAX_PER_ACCOUNT && (
|
||||
<p className="text-[10px] text-amber-400">⚠ 한도 {totalCount - MAX_PER_ACCOUNT}개 초과</p>
|
||||
<p className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||
⚠ 한도 {totalCount - MAX_PER_ACCOUNT}개 초과
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -259,7 +318,10 @@ export default function CharacterPanel({
|
|||
<div className="shrink-0">
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
|
||||
<span
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{ color: 'var(--input-icon)' }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M10 10L14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
|
|
@ -270,42 +332,62 @@ export default function CharacterPanel({
|
|||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); if (error) setError('') }}
|
||||
placeholder="캐릭터 닉네임 검색"
|
||||
className="w-full rounded-lg border-2 border-white/10 bg-gray-950 pl-10 pr-3 py-2.5 text-sm outline-none focus:border-emerald-500/60 hover:border-white/20 transition"
|
||||
className="w-full rounded-lg border-2 pl-10 pr-3 py-2.5 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: 'var(--input-border)',
|
||||
color: 'var(--text-strong)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={searchMutation.isPending}
|
||||
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-5 py-2.5 text-sm font-medium transition shrink-0 shadow-lg shadow-emerald-500/20"
|
||||
className="rounded-lg disabled:opacity-50 px-5 py-2.5 text-sm font-medium shrink-0 hover:bg-[var(--btn-primary-bg-hover)]"
|
||||
style={{
|
||||
background: 'var(--btn-primary-bg)',
|
||||
color: 'var(--btn-primary-text)',
|
||||
boxShadow: 'var(--btn-primary-shadow)',
|
||||
}}
|
||||
>
|
||||
{searchMutation.isPending ? '...' : '추가'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="text-xs text-red-400 mt-1.5">{error}</p>}
|
||||
{error && (
|
||||
<p className="text-xs mt-1.5" style={{ color: 'var(--danger-text)' }}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 캐릭터 목록 (스크롤) */}
|
||||
{characters.length > 0 && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto -mx-4 px-4">
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={characters}
|
||||
onReorder={onReorder}
|
||||
className="space-y-2"
|
||||
>
|
||||
{characters.map((char) => (
|
||||
<CharacterItem
|
||||
key={char.character_name}
|
||||
char={char}
|
||||
isSelected={selectedName === char.character_name}
|
||||
selections={allSelections[char.character_name] || {}}
|
||||
bosses={bosses}
|
||||
onSelect={onSelect}
|
||||
onRemove={setConfirmRemove}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
<OverlayScrollbarsComponent
|
||||
className="flex-1 min-h-0 -mx-4"
|
||||
options={{
|
||||
scrollbars: { theme: 'os-theme-maple os-theme-dark', autoHide: 'leave', autoHideDelay: 800 },
|
||||
}}
|
||||
defer
|
||||
>
|
||||
<div className="px-4">
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={characters}
|
||||
onReorder={onReorder}
|
||||
className="space-y-2"
|
||||
>
|
||||
{characters.map((char) => (
|
||||
<CharacterItem
|
||||
key={char.character_name}
|
||||
char={char}
|
||||
isSelected={selectedName === char.character_name}
|
||||
selections={allSelections[char.character_name] || {}}
|
||||
bosses={bosses}
|
||||
onSelect={onSelect}
|
||||
onRemove={setConfirmRemove}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { api } from '../../api/client'
|
||||
|
|
@ -8,11 +8,10 @@ import {
|
|||
WEEKLY_BOSSES,
|
||||
MONTHLY_BOSSES,
|
||||
calcPoints,
|
||||
addWeeks,
|
||||
formatDate,
|
||||
todayKST,
|
||||
} from './data'
|
||||
import WeekCard from './components/WeekCard'
|
||||
import { useLiberationStore } from './store'
|
||||
import QuestSelector from './components/QuestSelector'
|
||||
import PointsInput from './components/PointsInput'
|
||||
import ProgressBar from './components/ProgressBar'
|
||||
|
|
@ -21,15 +20,6 @@ import DatePicker from '../../components/DatePicker'
|
|||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import { useLayout } from '../../components/Layout'
|
||||
|
||||
const STORAGE_KEY = 'maple-liberation'
|
||||
|
||||
function makeEmptyWeek(startDate) {
|
||||
return {
|
||||
startDate: dayjs(startDate).toISOString(),
|
||||
...makeEmptyWeekly(),
|
||||
}
|
||||
}
|
||||
|
||||
function makeEmptyWeekly() {
|
||||
const bosses = {}
|
||||
WEEKLY_BOSSES.forEach((b) => {
|
||||
|
|
@ -69,9 +59,6 @@ function calcMonthlyEarn(weekData) {
|
|||
return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage)
|
||||
}
|
||||
|
||||
function calcMonthlyDoneEarn(weekData) {
|
||||
return weekData.blackMage?.done ? bossEarn(MONTHLY_BOSSES[0], weekData.blackMage) : 0
|
||||
}
|
||||
|
||||
export default function Liberation() {
|
||||
const { setFullscreen } = useLayout()
|
||||
|
|
@ -93,83 +80,12 @@ export default function Liberation() {
|
|||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const [state, setState] = useState(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved)
|
||||
if (!parsed.weekly) parsed.weekly = makeEmptyWeekly()
|
||||
if (!parsed.startDate) parsed.startDate = dayjs(todayKST()).toISOString()
|
||||
if (!parsed.weekOverrides) parsed.weekOverrides = {}
|
||||
// enabled/'none' 필드 제거 마이그레이션
|
||||
const migrate = (sel, defaultDiff) => {
|
||||
if (!sel) return sel
|
||||
if (!sel.difficulty || sel.difficulty === 'none') sel.difficulty = defaultDiff
|
||||
delete sel.enabled
|
||||
return sel
|
||||
}
|
||||
WEEKLY_BOSSES.forEach((b) => {
|
||||
if (parsed.weekly.bosses?.[b.key]) {
|
||||
parsed.weekly.bosses[b.key] = migrate(parsed.weekly.bosses[b.key], b.difficulties[0].key)
|
||||
}
|
||||
})
|
||||
parsed.weekly.blackMage = migrate(parsed.weekly.blackMage, MONTHLY_BOSSES[0].difficulties[0].key)
|
||||
return parsed
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return {
|
||||
startChapter: 0,
|
||||
currentPoints: 0,
|
||||
startDate: dayjs(todayKST()).toISOString(),
|
||||
weekly: makeEmptyWeekly(),
|
||||
weekOverrides: {},
|
||||
weeks: [makeEmptyWeek(todayKST())],
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
}, [state])
|
||||
|
||||
// 주차별 계산
|
||||
const progressByWeek = useMemo(() => {
|
||||
const result = []
|
||||
const startConsumedBefore = GENESIS_CHAPTERS
|
||||
.slice(0, state.startChapter)
|
||||
.reduce((s, c) => s + c.required, 0)
|
||||
const currentChapterCap = GENESIS_CHAPTERS[state.startChapter]?.required ?? 0
|
||||
const clampedCurrent = Math.min(state.currentPoints, currentChapterCap)
|
||||
let totalAccumulated = startConsumedBefore + clampedCurrent
|
||||
|
||||
for (const week of state.weeks) {
|
||||
const earned = calcWeekPoints(week)
|
||||
totalAccumulated += earned
|
||||
|
||||
let temp = totalAccumulated
|
||||
let chapterIdx = 0
|
||||
while (chapterIdx < GENESIS_CHAPTERS.length && temp >= GENESIS_CHAPTERS[chapterIdx].required) {
|
||||
temp -= GENESIS_CHAPTERS[chapterIdx].required
|
||||
chapterIdx++
|
||||
}
|
||||
|
||||
const isCompleted = totalAccumulated >= GENESIS_TOTAL
|
||||
const chapterInfo = isCompleted
|
||||
? { name: '완료', current: GENESIS_TOTAL, required: GENESIS_TOTAL }
|
||||
: {
|
||||
name: GENESIS_CHAPTERS[chapterIdx]?.boss || '',
|
||||
current: temp,
|
||||
required: GENESIS_CHAPTERS[chapterIdx]?.required || 0,
|
||||
}
|
||||
|
||||
result.push({
|
||||
points: earned,
|
||||
cumulative: totalAccumulated,
|
||||
completed: isCompleted,
|
||||
chapterInfo,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [state])
|
||||
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
|
||||
|
|
@ -192,35 +108,137 @@ export default function Liberation() {
|
|||
const monthlyEarn = calcMonthlyEarn(state.weekly)
|
||||
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
|
||||
|
||||
// 주차별 모드에서는 모든 주차 합산 (검은 마법사는 월별 슬롯 1회만 카운트)
|
||||
const headerWeekly = calcMode === 'weekly'
|
||||
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
|
||||
: weeklyEarn
|
||||
const headerMonthly = (() => {
|
||||
if (calcMode !== 'weekly') return monthlyEarn
|
||||
const sw = state.schedulerWeeks || []
|
||||
if (!state.startDate) return 0
|
||||
const claimed = {}
|
||||
sw.forEach((w, idx) => {
|
||||
const diff = w.config.blackMage?.difficulty
|
||||
if (!diff || diff === 'none') return
|
||||
const r = (() => {
|
||||
const start = dayjs(state.startDate).tz('Asia/Seoul').startOf('day')
|
||||
const dow = start.day()
|
||||
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
|
||||
const nextThu = start.add(daysToNextThu, 'day')
|
||||
if (idx + 1 === 1) return { start, end: nextThu.subtract(1, 'day') }
|
||||
const ws = nextThu.add((idx + 1 - 2) * 7, 'day')
|
||||
return { start: ws, end: ws.add(6, 'day') }
|
||||
})()
|
||||
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
|
||||
for (const m of months) {
|
||||
if (!(m in claimed)) {
|
||||
claimed[m] = bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return Object.values(claimed).reduce((s, v) => s + v, 0)
|
||||
})()
|
||||
|
||||
// 날짜 이벤트 시뮬레이션으로 해방일 계산
|
||||
function computeCompletionDate() {
|
||||
if (alreadyDone) return todayKST()
|
||||
if (weeklyEarn === 0 && monthlyEarn === 0) return null
|
||||
if (remaining <= 0) return dayjs(state.startDate).tz('Asia/Seoul').startOf('day').toDate()
|
||||
|
||||
const startKST = dayjs(state.startDate).tz('Asia/Seoul').startOf('day')
|
||||
const events = []
|
||||
|
||||
// 시작일 당일: (주간 - 완료된 주간) + (이번 달 월간, 아직 안 잡았을 때)
|
||||
const day0Weekly = Math.max(weeklyEarn - doneEarn, 0)
|
||||
const day0Monthly = monthlyEarn > 0 && !monthlyDoneThisMonth ? monthlyEarn : 0
|
||||
events.push({ date: startKST, amount: day0Weekly + day0Monthly })
|
||||
if (calcMode === 'weekly') {
|
||||
// 주차별 모드: 각 주차의 설정에 따라 적립
|
||||
const sw = state.schedulerWeeks || []
|
||||
if (sw.length === 0) return null
|
||||
// 주간 보스: 시작일 당일 = 1주차 설정, 이후 매 목요일 = 2주차/3주차 설정
|
||||
const dow = startKST.day()
|
||||
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
|
||||
// 1주차: 시작일 당일에 (주간 - done) 적립
|
||||
const week1Cfg = sw[0]?.config || makeEmptyWeekly()
|
||||
const w1Weekly = calcWeekPoints(week1Cfg)
|
||||
const w1Done = calcDoneEarn(week1Cfg)
|
||||
events.push({ date: startKST, amount: Math.max(w1Weekly - w1Done, 0) })
|
||||
// 2주차 이후: 각 목요일에 해당 주차 설정의 주간 합 적립
|
||||
// 마지막 주차 이후로는 마지막 주차 설정을 반복 적용
|
||||
let nextThu = startKST.add(daysToNextThu, 'day')
|
||||
for (let i = 1; i < 520; i++) {
|
||||
const cfg = sw[i]?.config || sw[sw.length - 1]?.config || makeEmptyWeekly()
|
||||
events.push({ date: nextThu, amount: calcWeekPoints(cfg) })
|
||||
nextThu = nextThu.add(1, 'week')
|
||||
}
|
||||
|
||||
// 다음 목요일부터 매주 주간 적립
|
||||
const dow = startKST.day()
|
||||
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
|
||||
let nextThu = startKST.add(daysToNextThu, 'day')
|
||||
for (let i = 0; i < 520; i++) {
|
||||
events.push({ date: nextThu, amount: weeklyEarn })
|
||||
nextThu = nextThu.add(1, 'week')
|
||||
}
|
||||
// 검은 마법사: 슬롯 배정에 따라 해당 주차의 첫날(or 1주차이면 시작일)에 적립
|
||||
const claimed = {} // monthKey -> { weekIdx, earn, doneAlready }
|
||||
sw.forEach((w, i) => {
|
||||
const diff = w.config.blackMage?.difficulty
|
||||
if (!diff || diff === 'none') return
|
||||
const range = getSchedulerWeekRange(state.startDate, i + 1)
|
||||
const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')]
|
||||
for (const m of months) {
|
||||
if (!(m in claimed)) {
|
||||
claimed[m] = {
|
||||
weekIdx: i,
|
||||
earn: bossEarn(MONTHLY_BOSSES[0], w.config.blackMage),
|
||||
done: !!w.config.blackMage.done,
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
Object.entries(claimed).forEach(([, info]) => {
|
||||
if (info.done) return
|
||||
const wIdx = info.weekIdx
|
||||
// 1주차면 시작일, 그 외엔 해당 주차의 시작 목요일
|
||||
const date = wIdx === 0
|
||||
? startKST
|
||||
: startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day')
|
||||
events.push({ date, amount: info.earn })
|
||||
})
|
||||
|
||||
// 다음 달 1일부터 매월 월간 적립
|
||||
if (monthlyEarn > 0) {
|
||||
let nextMonth = startKST.add(1, 'month').startOf('month')
|
||||
for (let i = 0; i < 120; i++) {
|
||||
events.push({ date: nextMonth, amount: monthlyEarn })
|
||||
nextMonth = nextMonth.add(1, 'month')
|
||||
// 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용
|
||||
const lastCfg = sw[sw.length - 1]?.config
|
||||
const lastBmEarn = lastCfg ? bossEarn(MONTHLY_BOSSES[0], lastCfg.blackMage) : 0
|
||||
if (lastBmEarn > 0) {
|
||||
const lastWeekStart = sw.length === 1
|
||||
? startKST
|
||||
: startKST.add(daysToNextThu + (sw.length - 2) * 7, 'day')
|
||||
const claimedMonths = new Set(Object.keys(claimed))
|
||||
let cursor = lastWeekStart.add(1, 'month').startOf('month')
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const m = cursor.format('YYYY-MM')
|
||||
if (!claimedMonths.has(m)) {
|
||||
events.push({ date: cursor, amount: lastBmEarn })
|
||||
}
|
||||
cursor = cursor.add(1, 'month')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 단순 계산 모드: 매주 동일 설정
|
||||
if (weeklyEarn === 0 && monthlyEarn === 0) return null
|
||||
|
||||
// 시작일 당일: (주간 - 완료된 주간) + (이번 달 월간, 아직 안 잡았을 때)
|
||||
const day0Weekly = Math.max(weeklyEarn - doneEarn, 0)
|
||||
const day0Monthly = monthlyEarn > 0 && !monthlyDoneThisMonth ? monthlyEarn : 0
|
||||
events.push({ date: startKST, amount: day0Weekly + day0Monthly })
|
||||
|
||||
// 다음 목요일부터 매주 주간 적립
|
||||
const dow = startKST.day()
|
||||
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
|
||||
let nextThu = startKST.add(daysToNextThu, 'day')
|
||||
for (let i = 0; i < 520; i++) {
|
||||
events.push({ date: nextThu, amount: weeklyEarn })
|
||||
nextThu = nextThu.add(1, 'week')
|
||||
}
|
||||
|
||||
// 다음 달 1일부터 매월 월간 적립
|
||||
if (monthlyEarn > 0) {
|
||||
let nextMonth = startKST.add(1, 'month').startOf('month')
|
||||
for (let i = 0; i < 120; i++) {
|
||||
events.push({ date: nextMonth, amount: monthlyEarn })
|
||||
nextMonth = nextMonth.add(1, 'month')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -233,88 +251,104 @@ export default function Liberation() {
|
|||
return null
|
||||
}
|
||||
|
||||
function getSchedulerWeekRange(startDateStr, weekIdx) {
|
||||
const start = dayjs(startDateStr).tz('Asia/Seoul').startOf('day')
|
||||
const dow = start.day()
|
||||
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
|
||||
const nextThu = start.add(daysToNextThu, 'day')
|
||||
if (weekIdx === 1) return { start, end: nextThu.subtract(1, 'day') }
|
||||
const ws = nextThu.add((weekIdx - 2) * 7, 'day')
|
||||
return { start: ws, end: ws.add(6, 'day') }
|
||||
}
|
||||
|
||||
const completionDate = computeCompletionDate()
|
||||
const isDone = completionDate !== null
|
||||
|
||||
const updateWeek = (idx, newWeekData) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
weeks: prev.weeks.map((w, i) => (i === idx ? newWeekData : w)),
|
||||
}))
|
||||
}
|
||||
|
||||
const addWeek = () => {
|
||||
setState((prev) => {
|
||||
const lastWeek = prev.weeks[prev.weeks.length - 1]
|
||||
const nextStart = addWeeks(lastWeek.startDate, 1)
|
||||
return {
|
||||
...prev,
|
||||
weeks: [...prev.weeks, { ...lastWeek, startDate: dayjs(nextStart).toISOString() }],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeWeek = (idx) => {
|
||||
setState((prev) => ({ ...prev, weeks: prev.weeks.filter((_, i) => i !== idx) }))
|
||||
}
|
||||
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const doReset = () => {
|
||||
setState({
|
||||
startChapter: 0,
|
||||
currentPoints: 0,
|
||||
startDate: dayjs(todayKST()).toISOString(),
|
||||
weekly: makeEmptyWeekly(),
|
||||
weekOverrides: {},
|
||||
weeks: [makeEmptyWeek(todayKST())],
|
||||
})
|
||||
resetSlot()
|
||||
setResetOpen(false)
|
||||
}
|
||||
|
||||
const setFirstWeekDate = (dateStr) => {
|
||||
setState((prev) => {
|
||||
const weeks = prev.weeks.map((w, i) => ({
|
||||
...w,
|
||||
startDate: dayjs(addWeeks(dateStr, i)).toISOString(),
|
||||
}))
|
||||
return { ...prev, weeks }
|
||||
})
|
||||
}
|
||||
|
||||
const totalCumulative = progressByWeek[progressByWeek.length - 1]?.cumulative
|
||||
|| (GENESIS_CHAPTERS.slice(0, state.startChapter).reduce((s, c) => s + c.required, 0) + state.currentPoints)
|
||||
const overallProgress = Math.min((totalCumulative / GENESIS_TOTAL) * 100, 100)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
{/* 해방 종류 탭 */}
|
||||
<div className="max-w-2xl mx-auto flex gap-2">
|
||||
<div className="max-w-3xl mx-auto flex gap-2">
|
||||
{[
|
||||
{ key: 'genesis', label: '제네시스 해방', img: genesisImg.data?.url },
|
||||
{ key: 'destiny', label: '데스티니 해방', img: destinyImg.data?.url },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setLiberationType(tab.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-3 rounded-2xl border px-5 py-3 transition ${
|
||||
liberationType === tab.key
|
||||
? 'border-emerald-500/50 bg-emerald-500/10 text-emerald-200 shadow-lg shadow-emerald-500/10'
|
||||
: 'border-white/10 bg-gray-900/40 text-gray-400 hover:border-white/20 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.img && <img src={tab.img} alt="" className="w-8 h-8 object-contain" />}
|
||||
<span className="text-base font-semibold">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
].map((tab) => {
|
||||
const active = liberationType === tab.key
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setLiberationType(tab.key)}
|
||||
className="flex-1 flex items-center justify-center gap-3 rounded-2xl border px-5 py-3"
|
||||
style={active ? {
|
||||
background: 'var(--selected-bg)',
|
||||
borderColor: 'var(--selected-border)',
|
||||
color: 'var(--accent-bright)',
|
||||
boxShadow: 'var(--btn-primary-shadow)',
|
||||
} : {
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{tab.img && <img src={tab.img} alt="" className="w-8 h-8 object-contain" />}
|
||||
<span className="text-base font-semibold">{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{liberationType === 'destiny' ? (
|
||||
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-16 text-center space-y-3 flex flex-col items-center justify-center" style={{ minHeight: 'calc(100vh - 220px)' }}>
|
||||
<div className="text-2xl font-bold text-gray-300">구현 예정</div>
|
||||
<div className="text-sm text-gray-500">데스티니 해방 계산기는 준비 중입니다.</div>
|
||||
<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}
|
||||
|
|
@ -322,12 +356,19 @@ export default function Liberation() {
|
|||
/>
|
||||
|
||||
{/* 현재 진행 상태 입력 */}
|
||||
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
|
||||
<div className="text-lg font-semibold text-emerald-300">현재 진행 상태</div>
|
||||
<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" style={{ gridTemplateColumns: '1.2fr 1.2fr 0.7fr' }}>
|
||||
<div className="grid gap-3 grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">시작 날짜</label>
|
||||
<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() }))}
|
||||
|
|
@ -335,7 +376,7 @@ export default function Liberation() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">진행 중인 퀘스트</label>
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
|
||||
<QuestSelector
|
||||
value={state.startChapter}
|
||||
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
|
||||
|
|
@ -343,13 +384,31 @@ export default function Liberation() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">현재 흔적</label>
|
||||
<PointsInput
|
||||
value={state.currentPoints}
|
||||
max={3000}
|
||||
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
|
||||
className="w-full h-12 rounded-lg border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 transition"
|
||||
/>
|
||||
<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>
|
||||
|
|
@ -357,15 +416,25 @@ export default function Liberation() {
|
|||
<WeeklyDefault
|
||||
weekly={state.weekly}
|
||||
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
||||
totalWeekly={weeklyEarn}
|
||||
totalMonthly={monthlyEarn}
|
||||
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-2xl mx-auto flex justify-end">
|
||||
<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 border-red-500/50 bg-red-500/10 hover:bg-red-500/20 text-red-300 hover:text-red-200 px-5 py-2.5 text-sm font-semibold transition shadow-lg shadow-red-500/10"
|
||||
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" />
|
||||
|
|
@ -380,7 +449,7 @@ export default function Liberation() {
|
|||
onClose={() => setResetOpen(false)}
|
||||
onConfirm={doReset}
|
||||
title="전체 초기화"
|
||||
description={'입력한 내용을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.'}
|
||||
description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
|
||||
confirmText="초기화"
|
||||
destructive
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../data'
|
||||
|
||||
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
||||
function formatKoreanDate(s) {
|
||||
const [y, m, d] = s.split('-')
|
||||
return `${y}년 ${m}월 ${d}일`
|
||||
const dow = DOW[new Date(`${s}T00:00:00+09:00`).getDay()]
|
||||
return `${y}년 ${m}월 ${d}일 (${dow})`
|
||||
}
|
||||
|
||||
export default function ProgressBar({ startChapter, currentPoints, completionDate }) {
|
||||
|
|
@ -17,9 +19,13 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
|
|||
|
||||
const renderSegment = ({ chapter, status, current }) => {
|
||||
const pct = (current / chapter.required) * 100
|
||||
const bg = status === 'done' ? '#10b981' : status === 'active' ? '#fbbf24' : 'transparent'
|
||||
const bg = status === 'done' ? 'var(--progress-emerald)' : status === 'active' ? 'var(--progress-amber)' : 'transparent'
|
||||
return (
|
||||
<div key={`seg-${chapter.idx}`} className="flex-1 h-2 rounded bg-gray-900 overflow-hidden">
|
||||
<div
|
||||
key={`seg-${chapter.idx}`}
|
||||
className="flex-1 h-2 rounded overflow-hidden"
|
||||
style={{ background: 'var(--progress-track)' }}
|
||||
>
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{ width: `${pct}%`, background: bg }}
|
||||
|
|
@ -29,60 +35,75 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
|
|||
}
|
||||
|
||||
const renderPortrait = ({ chapter, status }) => (
|
||||
<div key={`p-${chapter.idx}`} className="flex-1 flex flex-col items-center gap-1.5">
|
||||
<div className={`w-14 h-14 rounded-lg overflow-hidden border transition ${
|
||||
status === 'done' ? 'border-emerald-500/40' :
|
||||
status === 'active' ? 'border-amber-400/60 shadow-lg shadow-amber-500/20' :
|
||||
'border-white/5 opacity-50'
|
||||
<div key={`p-${chapter.idx}`} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
|
||||
<div className={`w-full aspect-square rounded-lg overflow-hidden ${
|
||||
status === 'active' ? 'shadow-lg shadow-amber-500/20' :
|
||||
status === 'pending' ? 'opacity-50' : ''
|
||||
}`}>
|
||||
<img
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.png`}
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.webp`}
|
||||
alt={chapter.boss}
|
||||
className={`w-full h-full object-cover ${status === 'pending' ? 'grayscale' : ''}`}
|
||||
className={`block w-full h-full object-cover ${status === 'pending' ? 'grayscale' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${
|
||||
status === 'done' ? 'text-emerald-300' :
|
||||
status === 'active' ? 'text-amber-300' : 'text-gray-500'
|
||||
}`}>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
color: status === 'done' ? 'var(--accent-bright)' :
|
||||
status === 'active' ? 'var(--warning-text-bright)' : 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
{chapter.boss}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-5">
|
||||
<div
|
||||
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-5"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
{/* 섹션 제목 */}
|
||||
<div className="text-lg font-semibold text-emerald-300">퀘스트 진행 상황</div>
|
||||
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>퀘스트 진행 상황</div>
|
||||
|
||||
{/* 1차 / 2차 라벨 + 세그먼트 바 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
<span className="text-base font-bold" style={{ color: '#5eead4' }}>1차 해방</span>
|
||||
<div style={{ width: '100%', height: 3, background: 'rgba(94, 234, 212, 0.5)', borderRadius: 999 }} />
|
||||
<span className="text-base font-bold" style={{ color: 'var(--liberation-primary)' }}>1차 해방</span>
|
||||
<div style={{ width: '100%', height: 3, background: 'var(--liberation-primary-bar)', borderRadius: 999 }} />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
<span className="text-base font-bold" style={{ color: '#fda4af' }}>2차 해방</span>
|
||||
<div style={{ width: '100%', height: 3, background: 'rgba(253, 164, 175, 0.5)', borderRadius: 999 }} />
|
||||
<span className="text-base font-bold" style={{ color: 'var(--liberation-secondary)' }}>2차 해방</span>
|
||||
<div style={{ width: '100%', height: 3, background: 'var(--liberation-secondary-bar)', borderRadius: 999 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-2">
|
||||
{chapterStates.map(renderSegment)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 초상화 (붙어있음) */}
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-2">
|
||||
{chapterStates.map(renderPortrait)}
|
||||
</div>
|
||||
|
||||
{/* 예상 해방 날짜 */}
|
||||
<div className="flex items-center justify-center gap-2 pt-4 border-t border-white/5 text-base">
|
||||
<span className="text-emerald-300/80">예상 해방 날짜</span>
|
||||
<span className="text-gray-600">·</span>
|
||||
<span className="font-semibold tabular-nums text-amber-400">
|
||||
{completionDate ? formatKoreanDate(completionDate) : <span className="text-gray-500 font-normal">미정</span>}
|
||||
<div
|
||||
className="flex items-center justify-center gap-3 pt-4 border-t"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<span className="text-lg font-semibold" style={{ color: 'var(--text-strong)' }}>예상 해방 날짜</span>
|
||||
<span style={{ color: 'var(--text-dim)' }}>·</span>
|
||||
<span
|
||||
className="text-xl font-bold tabular-nums"
|
||||
style={{ color: 'var(--warning-text-bright)' }}
|
||||
>
|
||||
{completionDate ? formatKoreanDate(completionDate) : <span className="font-normal" style={{ color: 'var(--text-dim)' }}>미정</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,23 +26,30 @@ export default function QuestSelector({ value, onChange }) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`w-full h-12 flex items-center gap-3 rounded-lg border bg-gray-950 pl-2 pr-3 transition ${
|
||||
open ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
className="w-full h-12 flex items-center gap-3 rounded-lg border pl-2 pr-3"
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: open ? 'var(--input-border-focus)' : 'var(--input-border)',
|
||||
color: 'var(--text-strong)',
|
||||
}}
|
||||
>
|
||||
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-900">
|
||||
<div
|
||||
className="w-9 h-9 rounded overflow-hidden shrink-0"
|
||||
style={{ background: 'var(--surface-nested)' }}
|
||||
>
|
||||
<img
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${selected.boss}.png`}
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${selected.boss}.webp`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-1 text-left text-sm font-medium text-gray-100">
|
||||
<span className="flex-1 text-left text-sm font-medium">
|
||||
{selected.boss}
|
||||
</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 12 12" fill="none"
|
||||
className={`text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
className={`transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
style={{ color: 'var(--input-icon)' }}
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
|
|
@ -55,7 +62,12 @@ export default function QuestSelector({ value, onChange }) {
|
|||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-white/10 bg-gray-900 shadow-2xl py-1 max-h-72 overflow-y-auto origin-top"
|
||||
className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border py-1 max-h-72 overflow-y-auto origin-top"
|
||||
style={{
|
||||
background: 'var(--popup-bg)',
|
||||
borderColor: 'var(--popup-border)',
|
||||
boxShadow: 'var(--popup-shadow)',
|
||||
}}
|
||||
>
|
||||
{GENESIS_CHAPTERS.map((chapter) => {
|
||||
const isSelected = chapter.idx === value
|
||||
|
|
@ -64,20 +76,25 @@ export default function QuestSelector({ value, onChange }) {
|
|||
key={chapter.idx}
|
||||
type="button"
|
||||
onClick={() => { onChange(chapter.idx); setOpen(false) }}
|
||||
className={`w-full flex items-center gap-3 px-2 py-1.5 transition ${
|
||||
isSelected ? 'bg-emerald-500/10' : 'hover:bg-white/5'
|
||||
}`}
|
||||
className="w-full flex items-center gap-3 px-2 py-1.5"
|
||||
style={isSelected ? { background: 'var(--option-selected-bg)' } : undefined}
|
||||
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'var(--row-hover-bg)' }}
|
||||
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = '' }}
|
||||
>
|
||||
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-950">
|
||||
<div
|
||||
className="w-9 h-9 rounded overflow-hidden shrink-0"
|
||||
style={{ background: 'var(--surface-nested)' }}
|
||||
>
|
||||
<img
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.png`}
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.webp`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className={`flex-1 text-left text-sm font-medium ${
|
||||
isSelected ? 'text-emerald-300' : 'text-gray-200'
|
||||
}`}>
|
||||
<span
|
||||
className="flex-1 text-left text-sm font-medium"
|
||||
style={{ color: isSelected ? 'var(--option-selected-text)' : 'var(--text-emphasis)' }}
|
||||
>
|
||||
{chapter.boss}
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
import Select from '../../../components/Select'
|
||||
import Checkbox from '../../../components/Checkbox'
|
||||
import Tooltip from '../../../components/Tooltip'
|
||||
import { WEEKLY_BOSSES, MONTHLY_BOSSES, BOSS_IMAGE_BASE, calcPoints, formatDate } from '../data'
|
||||
|
||||
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
||||
|
||||
/**
|
||||
* week: { startDate, bosses: { [bossKey]: { enabled, difficulty, party } }, includeBlackMage: {enabled, difficulty, party} }
|
||||
*/
|
||||
export default function WeekCard({ weekNumber, weekData, cumulativePoints, currentChapter, chapterInfo, onChange, weekProgress }) {
|
||||
const totalThisWeek = weekProgress.points
|
||||
const updateBoss = (bossKey, patch) => {
|
||||
const nextBosses = { ...weekData.bosses, [bossKey]: { ...weekData.bosses[bossKey], ...patch } }
|
||||
onChange({ ...weekData, bosses: nextBosses })
|
||||
}
|
||||
|
||||
const updateBlackMage = (patch) => {
|
||||
onChange({ ...weekData, blackMage: { ...weekData.blackMage, ...patch } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/5 bg-gray-900/40 overflow-hidden">
|
||||
{/* 헤더: 주차 번호 + 날짜 + 이번 주 획득 + 누적 */}
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3 bg-gray-950/60 border-b border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-semibold">{weekNumber}주차</div>
|
||||
<div className="text-xs text-gray-500">{formatDate(weekData.startDate)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="text-gray-400">
|
||||
획득 <span className="text-emerald-300 font-semibold tabular-nums">+{totalThisWeek}</span>
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
누적 <span className="text-white font-semibold tabular-nums">{cumulativePoints}</span>
|
||||
</div>
|
||||
{chapterInfo && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{chapterInfo.name} {chapterInfo.current}/{chapterInfo.required}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 보스 그리드 */}
|
||||
<div className="p-3 grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{WEEKLY_BOSSES.map((boss) => {
|
||||
const sel = weekData.bosses[boss.key] || { enabled: false, difficulty: boss.difficulties[0].key, party: 1 }
|
||||
const diff = boss.difficulties.find((d) => d.key === sel.difficulty) || boss.difficulties[0]
|
||||
const earned = sel.enabled ? calcPoints(diff.points, sel.party) : 0
|
||||
|
||||
return (
|
||||
<div key={boss.key} className={`rounded-lg border p-2 transition ${sel.enabled ? 'border-white/10 bg-gray-950/40' : 'border-white/5 bg-transparent opacity-60'}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Checkbox
|
||||
checked={sel.enabled}
|
||||
onChange={(v) => updateBoss(boss.key, { enabled: v })}
|
||||
size="sm"
|
||||
/>
|
||||
<Tooltip text={boss.name}>
|
||||
<img src={`${BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-7 h-7 rounded object-cover" />
|
||||
</Tooltip>
|
||||
<span className="text-xs font-medium truncate flex-1">{boss.name}</span>
|
||||
{earned > 0 && (
|
||||
<span className="text-xs text-emerald-300 font-semibold tabular-nums">+{earned}</span>
|
||||
)}
|
||||
</div>
|
||||
{sel.enabled && (
|
||||
<div className="flex gap-1.5">
|
||||
<Select
|
||||
value={sel.difficulty}
|
||||
onChange={(v) => updateBoss(boss.key, { difficulty: v })}
|
||||
options={boss.difficulties.map((d) => ({ value: d.key, label: `${d.label} +${d.points}` }))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={sel.party}
|
||||
onChange={(v) => updateBoss(boss.key, { party: v })}
|
||||
options={PARTY_OPTIONS}
|
||||
className="w-16"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 검은 마법사 (월 1회) */}
|
||||
{MONTHLY_BOSSES.map((boss) => {
|
||||
const sel = weekData.blackMage || { enabled: false, difficulty: boss.difficulties[0].key, party: 1 }
|
||||
const diff = boss.difficulties.find((d) => d.key === sel.difficulty) || boss.difficulties[0]
|
||||
const earned = sel.enabled ? calcPoints(diff.points, sel.party) : 0
|
||||
|
||||
return (
|
||||
<div key={boss.key} className={`rounded-lg border p-2 transition col-span-2 sm:col-span-2 ${
|
||||
sel.enabled ? 'border-amber-500/40 bg-amber-500/[0.05]' : 'border-white/5 bg-transparent opacity-60'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Checkbox
|
||||
checked={sel.enabled}
|
||||
onChange={(v) => updateBlackMage({ enabled: v })}
|
||||
size="sm"
|
||||
/>
|
||||
<Tooltip text={`${boss.name} (월 1회)`}>
|
||||
<img src={`${BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-7 h-7 rounded object-cover" />
|
||||
</Tooltip>
|
||||
<span className="text-xs font-medium flex-1">{boss.name} <span className="text-[10px] text-amber-400">월간</span></span>
|
||||
{earned > 0 && (
|
||||
<span className="text-xs text-emerald-300 font-semibold tabular-nums">+{earned}</span>
|
||||
)}
|
||||
</div>
|
||||
{sel.enabled && (
|
||||
<div className="flex gap-1.5">
|
||||
<Select
|
||||
value={sel.difficulty}
|
||||
onChange={(v) => updateBlackMage({ difficulty: v })}
|
||||
options={boss.difficulties.map((d) => ({ value: d.key, label: `${d.label} +${d.points}` }))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={sel.party}
|
||||
onChange={(v) => updateBlackMage({ party: v })}
|
||||
options={PARTY_OPTIONS}
|
||||
className="w-16"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,34 +1,41 @@
|
|||
import { useState } from 'react'
|
||||
import Select from '../../../components/Select'
|
||||
import Tooltip from '../../../components/Tooltip'
|
||||
import WeeklyScheduler from './WeeklyScheduler'
|
||||
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 }
|
||||
|
||||
function diffLabel(d, party) {
|
||||
if (d.key === 'none') return <span className="text-gray-500">격파 불가</span>
|
||||
if (d.key === 'none') return <span style={{ color: 'var(--text-dim)' }}>격파 불가</span>
|
||||
const earned = calcPoints(d.points, party)
|
||||
return (
|
||||
<span>
|
||||
{d.label} <span className="text-emerald-400">+{earned}</span>
|
||||
{d.label} <span style={{ color: 'var(--accent-bright)' }}>+{earned}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function BossRow({ boss, sel, onChange, monthly = false }) {
|
||||
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) }))
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg px-3 h-14 transition">
|
||||
<div className="flex items-center gap-3 rounded-lg px-3 h-16">
|
||||
<Tooltip text={boss.name}>
|
||||
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt="" className="w-8 h-8 rounded 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-sm font-medium flex-1 truncate">
|
||||
<span className="text-base font-semibold flex-1 truncate">
|
||||
{boss.name}
|
||||
{monthly && <span className="ml-1.5 text-[10px] text-amber-400/80">월간</span>}
|
||||
{monthly && (
|
||||
<span
|
||||
className="ml-1.5 text-[11px] font-medium"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
월간
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="w-36">
|
||||
|
|
@ -49,27 +56,32 @@ function BossRow({ boss, sel, onChange, monthly = false }) {
|
|||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange({ done: !sel.done })}
|
||||
className={`shrink-0 w-20 rounded-md h-8 text-xs font-semibold transition border ${
|
||||
disabled
|
||||
? 'border-white/5 text-gray-700 cursor-not-allowed'
|
||||
: sel.done
|
||||
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-300'
|
||||
: 'border-white/10 text-gray-500 hover:border-white/20 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{sel.done ? '완료' : '미완료'}
|
||||
</button>
|
||||
{showDone && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange({ done: !sel.done })}
|
||||
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border disabled:cursor-not-allowed"
|
||||
style={disabled ? {
|
||||
borderColor: 'var(--panel-border)',
|
||||
color: 'var(--text-dim)',
|
||||
} : sel.done ? {
|
||||
background: 'var(--selected-bg)',
|
||||
borderColor: 'var(--selected-border)',
|
||||
color: 'var(--accent-bright)',
|
||||
} : {
|
||||
borderColor: 'var(--btn-border)',
|
||||
color: 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
{sel.done ? '완료' : '미완료'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMonthly }) {
|
||||
const [mode, setMode] = useState('simple') // 'simple' | 'weekly'
|
||||
|
||||
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 } } })
|
||||
}
|
||||
|
|
@ -78,66 +90,68 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
|
||||
<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="flex items-center justify-between">
|
||||
<div className="text-lg font-semibold text-emerald-300">주간 보스 설정</div>
|
||||
<div className="inline-flex rounded-lg border border-white/10 bg-gray-950 p-0.5">
|
||||
<TabButton active={mode === 'simple'} onClick={() => setMode('simple')}>단순 계산</TabButton>
|
||||
<TabButton active={mode === 'weekly'} onClick={() => setMode('weekly')}>주차별 계산</TabButton>
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-semibold" style={{ color: 'var(--accent-bright)' }}>+{totalWeekly + totalMonthly}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'simple' ? (
|
||||
<>
|
||||
<div className="flex items-baseline justify-end text-sm text-gray-400 gap-3">
|
||||
<span>
|
||||
주간 획득 <span className="text-emerald-300 font-semibold tabular-nums">+{totalWeekly}</span>
|
||||
</span>
|
||||
<span>
|
||||
월간 획득 <span className="text-amber-300 font-semibold tabular-nums">+{totalMonthly}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{WEEKLY_BOSSES.map((boss) => (
|
||||
<div>
|
||||
{WEEKLY_BOSSES.map((boss, i) => (
|
||||
<div
|
||||
key={boss.key}
|
||||
className={i > 0 ? 'border-t' : ''}
|
||||
style={i > 0 ? { borderColor: 'var(--row-divider)' } : undefined}
|
||||
>
|
||||
<BossRow
|
||||
key={boss.key}
|
||||
boss={boss}
|
||||
sel={weekly.bosses[boss.key]}
|
||||
onChange={(patch) => updateBoss(boss.key, patch)}
|
||||
/>
|
||||
))}
|
||||
{MONTHLY_BOSSES.map((boss) => (
|
||||
</div>
|
||||
))}
|
||||
{MONTHLY_BOSSES.map((boss) => (
|
||||
<div
|
||||
key={boss.key}
|
||||
className="border-t"
|
||||
style={{ borderColor: 'var(--row-divider)' }}
|
||||
>
|
||||
<BossRow
|
||||
key={boss.key}
|
||||
boss={boss}
|
||||
sel={weekly.blackMage}
|
||||
onChange={updateBlackMage}
|
||||
monthly
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
주차별 계산 UI 준비 중
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<WeeklyScheduler
|
||||
startDate={startDate}
|
||||
weeks={weeks}
|
||||
onChangeWeeks={onChangeWeeks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`px-3 h-8 rounded-md text-sm font-medium transition ${
|
||||
active
|
||||
? 'bg-emerald-500/20 text-emerald-300'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
351
frontend/src/features/liberation/components/WeeklyScheduler.jsx
Normal file
351
frontend/src/features/liberation/components/WeeklyScheduler.jsx
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import dayjs from 'dayjs'
|
||||
import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES, calcPoints } from '../data'
|
||||
import { BossRow } from './WeeklyDefault'
|
||||
|
||||
function bossEarn(boss, sel) {
|
||||
if (!sel || !sel.difficulty || sel.difficulty === 'none') return 0
|
||||
const d = boss.difficulties.find((x) => x.key === sel.difficulty)
|
||||
if (!d) return 0
|
||||
return calcPoints(d.points, sel.party)
|
||||
}
|
||||
|
||||
function calcWeeklySum(config) {
|
||||
let sum = 0
|
||||
WEEKLY_BOSSES.forEach((b) => { sum += bossEarn(b, config.bosses[b.key]) })
|
||||
return sum
|
||||
}
|
||||
|
||||
const KST = 'Asia/Seoul'
|
||||
|
||||
// 주차별 날짜 범위 계산 (목요일 리셋, 1주차는 시작일부터 다음 목요일 전까지)
|
||||
function getWeekRange(startDateStr, weekIdx) {
|
||||
const start = dayjs(startDateStr).tz(KST).startOf('day')
|
||||
const dow = start.day()
|
||||
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
|
||||
const nextThu = start.add(daysToNextThu, 'day')
|
||||
if (weekIdx === 1) {
|
||||
return { start, end: nextThu.subtract(1, 'day') }
|
||||
}
|
||||
const weekStart = nextThu.add((weekIdx - 2) * 7, 'day')
|
||||
const weekEnd = weekStart.add(6, 'day')
|
||||
return { start: weekStart, end: weekEnd }
|
||||
}
|
||||
|
||||
function formatRange(r) {
|
||||
const fmt = (d) => `${d.month() + 1}/${d.date()}`
|
||||
return `${fmt(r.start)} ~ ${fmt(r.end)}`
|
||||
}
|
||||
|
||||
const DIFF_BADGE = {
|
||||
easy: { label: 'E', color: '#22c55e', border: 'rgba(34,197,94,0.4)', bg: 'rgba(34,197,94,0.15)' },
|
||||
normal: { label: 'N', color: '#60a5fa', border: 'rgba(96,165,250,0.4)', bg: 'rgba(96,165,250,0.15)' },
|
||||
hard: { label: 'H', color: '#f87171', border: 'rgba(248,113,113,0.4)', bg: 'rgba(248,113,113,0.15)' },
|
||||
chaos: { label: 'C', color: '#c084fc', border: 'rgba(192,132,252,0.45)', bg: 'rgba(192,132,252,0.15)' },
|
||||
extreme: { label: 'X', color: '#f59e0b', border: 'rgba(245,158,11,0.5)', bg: 'rgba(245,158,11,0.2)' },
|
||||
}
|
||||
|
||||
function makeEmptyWeek() {
|
||||
const bosses = {}
|
||||
WEEKLY_BOSSES.forEach((b) => {
|
||||
bosses[b.key] = { difficulty: 'none', party: 1, done: false }
|
||||
})
|
||||
return {
|
||||
bosses,
|
||||
blackMage: { difficulty: 'none', party: 1, done: false },
|
||||
}
|
||||
}
|
||||
|
||||
function BossAvatar({ boss, difficulty, size = 40 }) {
|
||||
const badge = DIFF_BADGE[difficulty]
|
||||
const enabled = difficulty && difficulty !== 'none'
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`rounded-md overflow-hidden border ${enabled ? '' : 'opacity-30 grayscale'}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
background: 'var(--surface-nested)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
style={{
|
||||
width: 16, height: 16,
|
||||
color: badge?.color || 'var(--text-dim)',
|
||||
background: badge?.bg || 'transparent',
|
||||
borderColor: badge?.border || 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
{badge?.label || '-'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
|
||||
const updateBoss = (key, patch) => {
|
||||
onChange({ ...config, bosses: { ...config.bosses, [key]: { ...config.bosses[key], ...patch } } })
|
||||
}
|
||||
const updateBlackMage = (patch) => {
|
||||
onChange({ ...config, blackMage: { ...config.blackMage, ...patch } })
|
||||
}
|
||||
|
||||
const blackmageLocked = monthlyLockedByWeek != null
|
||||
|
||||
return (
|
||||
<div>
|
||||
{WEEKLY_BOSSES.map((boss, i) => (
|
||||
<div
|
||||
key={boss.key}
|
||||
className={i > 0 ? 'border-t' : ''}
|
||||
style={i > 0 ? { borderColor: 'var(--row-divider)' } : undefined}
|
||||
>
|
||||
<BossRow
|
||||
boss={boss}
|
||||
sel={config.bosses[boss.key]}
|
||||
onChange={(patch) => updateBoss(boss.key, patch)}
|
||||
showDone={isCurrent}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={`border-t ${blackmageLocked ? 'opacity-40 pointer-events-none' : ''}`}
|
||||
style={{ borderColor: 'var(--row-divider)' }}
|
||||
>
|
||||
<BossRow
|
||||
boss={MONTHLY_BOSSES[0]}
|
||||
sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage}
|
||||
onChange={updateBlackMage}
|
||||
monthly
|
||||
showDone={isCurrent}
|
||||
/>
|
||||
</div>
|
||||
{blackmageLocked && (
|
||||
<div
|
||||
className="text-[11px] px-3 py-2"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
이번 달 검은 마법사는 {monthlyLockedByWeek}주차에 배정되어 있습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) {
|
||||
const weeks = weeksProp && weeksProp.length > 0
|
||||
? weeksProp
|
||||
: [{ id: 1, config: makeEmptyWeek() }]
|
||||
const setWeeks = (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(weeks) : updater
|
||||
onChangeWeeks?.(next)
|
||||
}
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
const nextId = () => (weeks[weeks.length - 1]?.id ?? 0) + 1
|
||||
|
||||
const addWeek = () => {
|
||||
const id = nextId()
|
||||
setWeeks((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeek()
|
||||
// done 상태는 복사하지 않음
|
||||
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
|
||||
if (base.blackMage) base.blackMage.done = false
|
||||
|
||||
// 새 주차의 달에 이미 검은 마법사가 배정되어 있으면 복사된 검은마법사는 초기화
|
||||
if (startDate && base.blackMage?.difficulty && base.blackMage.difficulty !== 'none') {
|
||||
const newIdx = prev.length + 1
|
||||
const newMonth = getWeekRange(startDate, newIdx).start.format('YYYY-MM')
|
||||
const existsInSameMonth = prev.some((p, i) => {
|
||||
if (!p.config.blackMage?.difficulty || p.config.blackMage.difficulty === 'none') return false
|
||||
return getWeekRange(startDate, i + 1).start.format('YYYY-MM') === newMonth
|
||||
})
|
||||
if (existsInSameMonth) {
|
||||
base.blackMage = { difficulty: 'none', party: 1, done: false }
|
||||
}
|
||||
}
|
||||
|
||||
return [...prev, { id, config: base }]
|
||||
})
|
||||
setExpanded(id)
|
||||
}
|
||||
|
||||
const removeWeek = (id) => {
|
||||
setWeeks((prev) => prev.filter((w) => w.id !== id))
|
||||
if (expanded === id) setExpanded(null)
|
||||
}
|
||||
|
||||
const updateWeek = (id, config) => {
|
||||
setWeeks((prev) => prev.map((w) => (w.id === id ? { ...w, config } : w)))
|
||||
}
|
||||
|
||||
// 검은 마법사 월별 슬롯 배정: 각 주차가 겹치는 달 중 하나를 선점
|
||||
const monthlyLocks = (() => {
|
||||
if (!startDate) return {}
|
||||
const claimed = {} // month -> weekNum (1-based)
|
||||
weeks.forEach((w, idx) => {
|
||||
const diff = w.config.blackMage?.difficulty
|
||||
if (!diff || diff === 'none') return
|
||||
const r = getWeekRange(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] = idx + 1
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
const locks = {}
|
||||
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]]
|
||||
}
|
||||
})
|
||||
return locks
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{weeks.map((w, idx) => {
|
||||
const n = idx + 1
|
||||
const isOpen = expanded === w.id
|
||||
const isCurrent = idx === 0 // 임시: 첫 번째가 현재 주차 (실제 연결 시 날짜 기반)
|
||||
// 검은마법사 잠금 판정은 아래 사전 계산된 monthlyLocks 사용
|
||||
const monthlyLockedByWeek = monthlyLocks[idx] ?? null
|
||||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
className="rounded-xl border"
|
||||
style={{
|
||||
background: 'var(--surface-3)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 pl-4 pr-2 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(isOpen ? null : w.id)}
|
||||
className="flex items-center gap-4 flex-1 text-left hover:opacity-90 transition"
|
||||
>
|
||||
<div className="w-12 text-center shrink-0">
|
||||
<div className="text-[11px] leading-tight" style={{ color: 'var(--text-dim)' }}>주차</div>
|
||||
<div
|
||||
className="text-xl font-extrabold tabular-nums leading-tight"
|
||||
style={{ color: 'var(--text-emphasis)' }}
|
||||
>
|
||||
{n}
|
||||
</div>
|
||||
</div>
|
||||
{startDate && (
|
||||
<div
|
||||
className="text-sm tabular-nums w-24 shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{formatRange(getWeekRange(startDate, n))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{WEEKLY_BOSSES.map((b) => (
|
||||
<BossAvatar key={b.key} boss={b} difficulty={w.config.bosses[b.key]?.difficulty} size={40} />
|
||||
))}
|
||||
<BossAvatar boss={MONTHLY_BOSSES[0]} difficulty={monthlyLockedByWeek != null ? 'none' : w.config.blackMage?.difficulty} size={40} />
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const weeklySum = calcWeeklySum(w.config)
|
||||
const monthlySum = monthlyLockedByWeek != null ? 0 : bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
|
||||
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>
|
||||
{monthlySum > 0 && (
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--warning-text-bright)' }}>+{monthlySum}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 12 12" fill="none"
|
||||
className={`transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`}
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeWeek(w.id)}
|
||||
disabled={weeks.length <= 1}
|
||||
title={weeks.length <= 1 ? '최소 한 주차는 유지되어야 합니다' : '이 주차 삭제'}
|
||||
className="shrink-0 w-8 h-8 rounded-md hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed flex items-center justify-center"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M1 1L13 13M13 1L1 13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
key="editor"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{
|
||||
height: { duration: 0.35, ease: [0.22, 1, 0.36, 1] },
|
||||
opacity: { duration: 0.25, ease: [0.22, 1, 0.36, 1] },
|
||||
}}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
className="border-t px-3 py-3"
|
||||
style={{
|
||||
borderColor: 'var(--row-divider)',
|
||||
background: 'var(--skeleton-bg)',
|
||||
}}
|
||||
>
|
||||
<WeekEditor
|
||||
config={w.config}
|
||||
onChange={(c) => updateWeek(w.id, c)}
|
||||
isCurrent={isCurrent}
|
||||
monthlyLockedByWeek={monthlyLockedByWeek}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addWeek}
|
||||
className="w-full rounded-xl border border-dashed py-3 text-sm font-semibold flex items-center justify-center gap-2 hover:border-[var(--selected-border)] hover:text-[var(--accent-bright)]"
|
||||
style={{
|
||||
borderColor: 'var(--dashed-border)',
|
||||
color: 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M7 1V13M1 7H13" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
주차 추가
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ export const GENESIS_CHAPTERS = [
|
|||
]
|
||||
|
||||
// 퀘스트 이미지 경로 (제네시스)
|
||||
export const QUEST_BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/quest/boss'
|
||||
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'
|
||||
|
|
@ -23,7 +23,7 @@ export const GENESIS_TOTAL = GENESIS_CHAPTERS.reduce((s, c) => s + c.required, 0
|
|||
// 주간 보스 (주 1회)
|
||||
export const WEEKLY_BOSSES = [
|
||||
{
|
||||
key: 'lotus', name: '스우', image: '스우.png',
|
||||
key: 'lotus', name: '스우', image: '스우.webp',
|
||||
difficulties: [
|
||||
{ key: 'normal', label: '노말', points: 10 },
|
||||
{ key: 'hard', label: '하드', points: 50 },
|
||||
|
|
@ -31,14 +31,14 @@ export const WEEKLY_BOSSES = [
|
|||
],
|
||||
},
|
||||
{
|
||||
key: 'damien', name: '데미안', image: '데미안.png',
|
||||
key: 'damien', name: '데미안', image: '데미안.webp',
|
||||
difficulties: [
|
||||
{ key: 'normal', label: '노말', points: 10 },
|
||||
{ key: 'hard', label: '하드', points: 50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'lucid', name: '루시드', image: '루시드.png',
|
||||
key: 'lucid', name: '루시드', image: '루시드.webp',
|
||||
difficulties: [
|
||||
{ key: 'easy', label: '이지', points: 15 },
|
||||
{ key: 'normal', label: '노말', points: 20 },
|
||||
|
|
@ -46,7 +46,7 @@ export const WEEKLY_BOSSES = [
|
|||
],
|
||||
},
|
||||
{
|
||||
key: 'will', name: '윌', image: '윌.png',
|
||||
key: 'will', name: '윌', image: '윌.webp',
|
||||
difficulties: [
|
||||
{ key: 'easy', label: '이지', points: 15 },
|
||||
{ key: 'normal', label: '노말', points: 25 },
|
||||
|
|
@ -54,21 +54,21 @@ export const WEEKLY_BOSSES = [
|
|||
],
|
||||
},
|
||||
{
|
||||
key: 'dusk', name: '더스크', image: '더스크.png',
|
||||
key: 'dusk', name: '더스크', image: '더스크.webp',
|
||||
difficulties: [
|
||||
{ key: 'normal', label: '노말', points: 20 },
|
||||
{ key: 'chaos', label: '카오스', points: 65 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'jinhilla', name: '진 힐라', image: '진 힐라.png',
|
||||
key: 'jinhilla', name: '진 힐라', image: '진 힐라.webp',
|
||||
difficulties: [
|
||||
{ key: 'normal', label: '노말', points: 45 },
|
||||
{ key: 'hard', label: '하드', points: 90 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'darknell', name: '듄켈', image: '듄켈.png',
|
||||
key: 'darknell', name: '듄켈', image: '듄켈.webp',
|
||||
difficulties: [
|
||||
{ key: 'normal', label: '노말', points: 25 },
|
||||
{ key: 'hard', label: '하드', points: 75 },
|
||||
|
|
@ -79,7 +79,7 @@ export const WEEKLY_BOSSES = [
|
|||
// 월간 보스
|
||||
export const MONTHLY_BOSSES = [
|
||||
{
|
||||
key: 'blackmage', name: '검은 마법사', image: '검은 마법사.png',
|
||||
key: 'blackmage', name: '검은 마법사', image: '검은 마법사.webp',
|
||||
difficulties: [
|
||||
{ key: 'hard', label: '하드', points: 600 },
|
||||
{ key: 'extreme', label: '익스트림', points: 600 },
|
||||
|
|
@ -95,15 +95,6 @@ export function calcPoints(basePoints, partySize) {
|
|||
return Math.floor(basePoints / partySize)
|
||||
}
|
||||
|
||||
// 목요일 기준 주차 계산 (KST)
|
||||
// 이번 주 목요일 자정 = 이번 주의 시작
|
||||
export function getThursdayOfWeek(date) {
|
||||
const d = dayjs(date).tz(KST)
|
||||
const day = d.day() // 0=일, 4=목
|
||||
const diff = (day - 4 + 7) % 7
|
||||
return d.subtract(diff, 'day').startOf('day').toDate()
|
||||
}
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
|
|
@ -117,10 +108,6 @@ export function formatDate(date) {
|
|||
return dayjs(date).tz(KST).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
export function addWeeks(date, weeks) {
|
||||
return dayjs(date).tz(KST).add(weeks, 'week').toDate()
|
||||
}
|
||||
|
||||
export function todayKST() {
|
||||
return dayjs().tz(KST).startOf('day').toDate()
|
||||
}
|
||||
|
|
|
|||
51
frontend/src/features/liberation/store.js
Normal file
51
frontend/src/features/liberation/store.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import dayjs from 'dayjs'
|
||||
import { WEEKLY_BOSSES, MONTHLY_BOSSES, todayKST } from './data'
|
||||
|
||||
function makeEmptyWeekly() {
|
||||
const bosses = {}
|
||||
WEEKLY_BOSSES.forEach((b) => {
|
||||
bosses[b.key] = { difficulty: 'none', party: 1, done: false }
|
||||
})
|
||||
return {
|
||||
bosses,
|
||||
blackMage: { difficulty: 'none', party: 1, done: false },
|
||||
}
|
||||
}
|
||||
|
||||
function makeInitialSlot() {
|
||||
return {
|
||||
startChapter: 0,
|
||||
currentPoints: 0,
|
||||
startDate: dayjs(todayKST()).toISOString(),
|
||||
weekly: makeEmptyWeekly(),
|
||||
schedulerWeeks: [{ id: 1, config: makeEmptyWeekly() }],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 해방 계산기 상태
|
||||
* calcMode: 'simple' | 'weekly'
|
||||
* simple / weekly: 각 모드 독립 슬롯
|
||||
*/
|
||||
export const useLiberationStore = create(persist(
|
||||
(set) => ({
|
||||
calcMode: 'simple',
|
||||
simple: makeInitialSlot(),
|
||||
weekly: makeInitialSlot(),
|
||||
|
||||
setCalcMode: (mode) => set({ calcMode: mode }),
|
||||
|
||||
updateSlot: (patch) => set((s) => ({
|
||||
[s.calcMode]: typeof patch === 'function'
|
||||
? patch(s[s.calcMode])
|
||||
: { ...s[s.calcMode], ...patch },
|
||||
})),
|
||||
|
||||
resetSlot: () => set((s) => ({ [s.calcMode]: makeInitialSlot() })),
|
||||
}),
|
||||
{ name: 'maple-liberation' },
|
||||
))
|
||||
|
||||
export { makeEmptyWeekly, makeInitialSlot }
|
||||
702
frontend/src/features/symbol/Symbol.jsx
Normal file
702
frontend/src/features/symbol/Symbol.jsx
Normal file
|
|
@ -0,0 +1,702 @@
|
|||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import { api } from '../../api/client'
|
||||
import { useLayout } from '../../components/Layout'
|
||||
import Select from '../../components/Select'
|
||||
import Tooltip from '../../components/Tooltip'
|
||||
import { useSymbolStore } from './store'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
const KST = 'Asia/Seoul'
|
||||
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
||||
|
||||
function formatKoreanDate(d) {
|
||||
const dj = dayjs(d).tz(KST)
|
||||
return `${dj.year()}년 ${String(dj.month() + 1).padStart(2, '0')}월 ${String(dj.date()).padStart(2, '0')}일 (${DOW[dj.day()]})`
|
||||
}
|
||||
|
||||
/**
|
||||
* 심볼 완료까지 남은 일수/예상 완료일 계산
|
||||
* - 일퀘는 매일, 주간퀘는 매주 목요일 리셋 시 N회분을 한 번에 지급한다고 가정
|
||||
* - extra(추가 심볼)는 즉시 적용
|
||||
* - dailyDone이면 오늘 일퀘는 이미 받은 걸로 간주 (내일부터 다시 지급)
|
||||
*/
|
||||
function computeCompletion({ remainingSymbols, daily, weeklyPerWeek, extra, dailyDone }) {
|
||||
const need = Math.max(remainingSymbols - extra, 0)
|
||||
if (need === 0) return { days: 0, date: dayjs().tz(KST).startOf('day').toDate() }
|
||||
if (daily <= 0 && weeklyPerWeek <= 0) return { days: null, date: null }
|
||||
|
||||
let acc = 0
|
||||
let cursor = dayjs().tz(KST).startOf('day')
|
||||
for (let day = 0; day < 3650; day++) {
|
||||
// 오늘은 dailyDone이면 일퀘 없음, 그 외엔 daily
|
||||
if (!(day === 0 && dailyDone)) acc += daily
|
||||
// 목요일(day=4)에 주간퀘 전량 지급
|
||||
if (cursor.day() === 4 && weeklyPerWeek > 0) acc += weeklyPerWeek
|
||||
if (acc >= need) return { days: day, date: cursor.toDate() }
|
||||
cursor = cursor.add(1, 'day')
|
||||
}
|
||||
return { days: null, date: null }
|
||||
}
|
||||
|
||||
function formatMesoKorean(n) {
|
||||
const v = Number(n) || 0
|
||||
if (v <= 0) return '0'
|
||||
const eok = Math.floor(v / 100_000_000)
|
||||
const man = Math.floor((v % 100_000_000) / 10_000)
|
||||
const parts = []
|
||||
if (eok) parts.push(`${eok.toLocaleString()}억`)
|
||||
if (man) parts.push(`${man.toLocaleString()}만`)
|
||||
return parts.length ? parts.join(' ') : v.toLocaleString()
|
||||
}
|
||||
|
||||
const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']
|
||||
|
||||
function CharacterCard({ char, active, onSelect, onRemove }) {
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
if (e.target.closest('button')) return
|
||||
onSelect()
|
||||
}}
|
||||
className="group relative shrink-0 w-36 rounded-xl border cursor-pointer select-none"
|
||||
style={{
|
||||
borderColor: active ? 'var(--selected-border)' : 'var(--panel-border)',
|
||||
background: active ? 'var(--selected-bg)' : 'var(--surface-3)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove() }}
|
||||
style={{ position: 'absolute', top: 6, right: 6, zIndex: 10, color: 'var(--text-dim)' }}
|
||||
className="w-6 h-6 rounded-md hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)] flex items-center justify-center text-base leading-none"
|
||||
aria-label="삭제"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="pt-3 px-3 pb-3 flex flex-col items-center text-center">
|
||||
<div className="w-24 h-24 overflow-hidden flex items-center justify-center">
|
||||
{char.character_image ? (
|
||||
<img
|
||||
src={char.character_image}
|
||||
alt=""
|
||||
className="w-full h-full object-contain scale-[3] origin-center pointer-events-none"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-3xl" style={{ color: 'var(--text-dim)' }}>?</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 text-base font-semibold truncate w-full"
|
||||
style={{ color: active ? 'var(--accent-bright)' : 'var(--text-emphasis)' }}
|
||||
>
|
||||
{char.character_name}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs tabular-nums mt-0.5 truncate w-full"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
Lv.{char.character_level} · {char.job_name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SymbolCard({ symbol, equipped, charId }) {
|
||||
const progress = useSymbolStore((s) => s.progress?.[charId]?.[symbol.id])
|
||||
const updateSymbol = useSymbolStore((s) => s.updateSymbol)
|
||||
|
||||
const dailyDone = progress?.dailyDone ?? false
|
||||
const weeklyCount = progress?.weeklyCount ?? 3
|
||||
const daily = progress?.daily ?? symbol.daily_default
|
||||
const extra = progress?.extra ?? 0
|
||||
const patch = (p) => charId && updateSymbol(charId, symbol.id, p)
|
||||
|
||||
const level = progress?.level ?? 0
|
||||
const growth = progress?.growth ?? 0
|
||||
const requireGrowth = symbol.levels?.find((l) => l.level === level)?.required_count || 0
|
||||
const isMax = equipped && level >= symbol.max_level
|
||||
|
||||
// 남은 심볼: 현재 레벨→만렙 까지 필요한 심볼 총합 (현재 성장치 차감)
|
||||
// 필요 메소: 현재 레벨→만렙 까지 필요한 메소 총합
|
||||
// 체납 메소: 이미 성장치가 현재 레벨 요구치 이상이면 바로 올릴 수 있는 레벨의 메소
|
||||
const { remainingSymbols, remainingMeso, arrearMeso } = useMemo(() => {
|
||||
if (!equipped || !symbol.levels?.length) return { remainingSymbols: 0, remainingMeso: 0, arrearMeso: 0 }
|
||||
let sym = 0, meso = 0, arr = 0
|
||||
// 체납: 현재 성장치로 올릴 수 있는 레벨까지 누적
|
||||
let arrLv = level, arrG = growth
|
||||
while (arrLv < symbol.max_level) {
|
||||
const req = symbol.levels.find((l) => l.level === arrLv)?.required_count
|
||||
const cost = symbol.levels.find((l) => l.level === arrLv)?.meso_cost
|
||||
if (req == null || cost == null || arrG < req) break
|
||||
arr += cost
|
||||
arrG -= req
|
||||
arrLv += 1
|
||||
}
|
||||
let g = growth
|
||||
for (const l of symbol.levels) {
|
||||
if (l.level < level) continue
|
||||
sym += Math.max(l.required_count - g, 0)
|
||||
g = Math.max(g - l.required_count, 0)
|
||||
meso += l.meso_cost
|
||||
}
|
||||
return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr }
|
||||
}, [equipped, level, growth, symbol.levels, symbol.max_level])
|
||||
|
||||
// 현재 성장치로 도달 가능한 최대 레벨 (연속 체납 반영)
|
||||
const reachableLevel = useMemo(() => {
|
||||
if (!equipped || isMax) return level
|
||||
let lv = level
|
||||
let g = growth
|
||||
while (lv < symbol.max_level) {
|
||||
const req = symbol.levels?.find((l) => l.level === lv)?.required_count
|
||||
if (!req || g < req) break
|
||||
g -= req
|
||||
lv += 1
|
||||
}
|
||||
return lv
|
||||
}, [equipped, isMax, level, growth, symbol.levels, symbol.max_level])
|
||||
|
||||
// 성장치로 만렙까지 도달 가능하지만 레벨업은 안 한 상태
|
||||
const effectivelyMax = equipped && !isMax && reachableLevel >= symbol.max_level
|
||||
const interactable = equipped && !isMax && !effectivelyMax
|
||||
|
||||
// 남은 일수/예상 완료일
|
||||
const { days: daysLeft, date: completeDate } = useMemo(() => {
|
||||
if (!equipped || isMax) return { days: null, date: null }
|
||||
return computeCompletion({
|
||||
remainingSymbols,
|
||||
daily,
|
||||
weeklyPerWeek: (weeklyCount || 0) * (symbol.weekly_default || 0),
|
||||
extra,
|
||||
dailyDone,
|
||||
})
|
||||
}, [equipped, isMax, remainingSymbols, daily, weeklyCount, symbol.weekly_default, extra, dailyDone])
|
||||
|
||||
const inputClass = "w-full h-10 rounded-md border px-3 text-base text-right tabular-nums outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)] disabled:opacity-50"
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-5"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
opacity: equipped ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-14 h-14 rounded-lg overflow-hidden shrink-0 flex items-center justify-center"
|
||||
style={{ background: 'var(--surface-nested)' }}
|
||||
>
|
||||
{symbol.image_url && (
|
||||
<img
|
||||
src={symbol.image_url}
|
||||
alt={symbol.region}
|
||||
className={`w-12 h-12 object-contain ${!equipped ? 'grayscale opacity-50' : ''}`}
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold truncate">{symbol.region}</div>
|
||||
<div
|
||||
className="text-sm tabular-nums mt-0.5"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
Lv.<span className="font-bold text-base" style={{ color: 'var(--accent-bright)' }}>{level}</span>
|
||||
<span style={{ color: 'var(--text-dim)' }}> / {symbol.max_level}</span>
|
||||
</div>
|
||||
</div>
|
||||
{equipped && !isMax && !effectivelyMax && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patch({ dailyDone: !dailyDone })}
|
||||
title="오늘 일퀘 완료 여부"
|
||||
className="shrink-0 rounded-md h-8 px-3 text-xs font-semibold border disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={dailyDone ? {
|
||||
background: 'var(--selected-bg)',
|
||||
borderColor: 'var(--selected-border)',
|
||||
color: 'var(--accent-bright)',
|
||||
} : {
|
||||
background: 'var(--danger-bg-hover)',
|
||||
borderColor: 'var(--icon-danger-border)',
|
||||
color: 'var(--danger-text)',
|
||||
}}
|
||||
>
|
||||
{dailyDone ? '금일 일퀘 완료' : '금일 일퀘 미완료'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 진행도 바 */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm tabular-nums mb-1.5">
|
||||
{isMax ? (
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 <span className="font-bold" style={{ color: 'var(--warning-text-bright)' }}>MAX</span>
|
||||
</span>
|
||||
) : effectivelyMax ? (
|
||||
<Tooltip text={`Lv.${symbol.max_level}까지 상승 가능`}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 {growth} <span className="font-bold" style={{ color: 'var(--warning-text-bright)' }}>(MAX)</span> / {requireGrowth}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : reachableLevel > level ? (
|
||||
<Tooltip text={`Lv.${reachableLevel}까지 상승 가능`}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 {growth} / {requireGrowth}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 {growth} / {requireGrowth}
|
||||
</span>
|
||||
)}
|
||||
{!isMax && !effectivelyMax && (
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
{requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="h-2 rounded-full overflow-hidden"
|
||||
style={{ background: 'var(--progress-track)' }}
|
||||
>
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: isMax || effectivelyMax ? '100%' : `${Math.min((growth / requireGrowth) * 100, 100)}%`,
|
||||
background: isMax || effectivelyMax ? 'var(--progress-amber)' : 'var(--progress-emerald)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 획득량 입력 */}
|
||||
<div
|
||||
className="grid gap-2 mb-4"
|
||||
style={{ gridTemplateColumns: symbol.weekly_default > 0 ? '0.7fr 1.3fr 1fr' : '1fr 1fr' }}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>일퀘 획득</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={equipped ? String(daily) : '0'}
|
||||
onChange={(e) => patch({ daily: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
|
||||
disabled={!interactable}
|
||||
className={inputClass}
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: 'var(--input-border)',
|
||||
color: 'var(--text-strong)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{symbol.weekly_default > 0 && (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>주간퀘 획득</label>
|
||||
<Select
|
||||
value={weeklyCount}
|
||||
onChange={(v) => patch({ weeklyCount: v })}
|
||||
options={[0, 1, 2, 3].map((n) => ({
|
||||
value: n,
|
||||
label: `${n * symbol.weekly_default}개`,
|
||||
}))}
|
||||
disabled={!interactable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>추가 심볼</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={equipped ? String(extra) : '0'}
|
||||
onChange={(e) => patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
|
||||
disabled={!interactable}
|
||||
className={inputClass}
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: 'var(--input-border)',
|
||||
color: 'var(--text-strong)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="text-base">
|
||||
{[
|
||||
{ label: '남은 심볼', value: equipped && !isMax && !effectivelyMax ? `${remainingSymbols.toLocaleString()}개` : '-', color: 'var(--text-emphasis)' },
|
||||
{ label: '필요 메소', value: equipped && !isMax ? remainingMeso.toLocaleString() : '-', color: 'var(--warning-text-bright)', tooltip: equipped && !isMax ? formatMesoKorean(remainingMeso) : null },
|
||||
{ label: '체납 메소', value: equipped && !isMax ? arrearMeso.toLocaleString() : '-', color: 'var(--danger-text)', tooltip: equipped && !isMax ? formatMesoKorean(arrearMeso) : null },
|
||||
{ label: '남은 일수', value: equipped && !isMax && !effectivelyMax && daysLeft != null ? `${daysLeft.toLocaleString()}일` : '-', color: 'var(--text-emphasis)' },
|
||||
{ label: '예상 완료일', value: equipped && !isMax && !effectivelyMax && completeDate ? formatKoreanDate(completeDate) : '-', color: equipped && !isMax && !effectivelyMax && completeDate ? 'var(--accent-bright)' : 'var(--text-dim)', strong: true },
|
||||
].map((row, i) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex justify-between py-2 border-t first:border-t-0"
|
||||
style={{ borderColor: 'var(--row-divider)' }}
|
||||
>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{row.label}</span>
|
||||
{row.tooltip ? (
|
||||
<Tooltip text={row.tooltip}>
|
||||
<span className={`tabular-nums ${row.strong ? 'font-semibold' : 'font-medium'}`} style={{ color: row.color }}>
|
||||
{row.value}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={`tabular-nums ${row.strong ? 'font-semibold' : 'font-medium'}`} style={{ color: row.color }}>
|
||||
{row.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Symbol() {
|
||||
const { setFullscreen } = useLayout()
|
||||
useEffect(() => {
|
||||
setFullscreen(true)
|
||||
return () => setFullscreen(false)
|
||||
}, [setFullscreen])
|
||||
|
||||
// 심볼 목록 (DB에서 로드)
|
||||
const { data: allSymbols = [] } = useQuery({
|
||||
queryKey: ['symbol', 'symbols'],
|
||||
queryFn: () => api('/api/symbols').catch(() => []),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const groups = {}
|
||||
for (const s of allSymbols) {
|
||||
if (!groups[s.type]) groups[s.type] = s
|
||||
}
|
||||
return TYPE_ORDER
|
||||
.filter((t) => groups[t])
|
||||
.map((t) => ({ key: t, label: `${t} 심볼`, image_url: groups[t].image_url }))
|
||||
}, [allSymbols])
|
||||
|
||||
const characters = useSymbolStore((s) => s.characters)
|
||||
const selectedCharId = useSymbolStore((s) => s.selectedCharId)
|
||||
const addCharacter = useSymbolStore((s) => s.addCharacter)
|
||||
const removeCharacter = useSymbolStore((s) => s.removeCharacter)
|
||||
const selectCharacter = useSymbolStore((s) => s.selectCharacter)
|
||||
const syncCharacterSymbols = useSymbolStore((s) => s.syncCharacterSymbols)
|
||||
const updateCharacter = useSymbolStore((s) => s.updateCharacter)
|
||||
const storedTab = useSymbolStore((s) => s.selectedTabs?.[selectedCharId])
|
||||
const setTabStore = useSymbolStore((s) => s.setTab)
|
||||
|
||||
const tab = storedTab || tabs[0]?.key || null
|
||||
const setTab = (t) => { if (selectedCharId) setTabStore(selectedCharId, t) }
|
||||
|
||||
// 각 캐릭터 기본정보(코디 이미지) 새로고침
|
||||
const basicQueries = useQueries({
|
||||
queries: characters.map((c) => ({
|
||||
queryKey: ['character', 'basic', c.character_name],
|
||||
queryFn: () => api(`/api/character/search?name=${encodeURIComponent(c.character_name)}`),
|
||||
enabled: !!c.character_name,
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
})),
|
||||
})
|
||||
useEffect(() => {
|
||||
characters.forEach((c, idx) => {
|
||||
const d = basicQueries[idx]?.data
|
||||
if (!d) return
|
||||
if (d.character_image !== c.character_image || d.character_level !== c.character_level || d.job_name !== c.job_name) {
|
||||
updateCharacter(c.id, {
|
||||
character_image: d.character_image,
|
||||
character_level: d.character_level,
|
||||
job_name: d.job_name,
|
||||
world_name: d.world_name,
|
||||
})
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [basicQueries.map((q) => q.dataUpdatedAt).join(',')])
|
||||
|
||||
// 각 캐릭터의 장착 심볼 fetch (새로고침마다 갱신)
|
||||
const symbolQueries = useQueries({
|
||||
queries: characters.map((c) => ({
|
||||
queryKey: ['character', 'symbols', c.id],
|
||||
queryFn: () => api(`/api/character/symbols?ocid=${c.id}`),
|
||||
enabled: !!c.id,
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 0,
|
||||
})),
|
||||
})
|
||||
|
||||
// symbolQueries 결과를 store로 반영
|
||||
useEffect(() => {
|
||||
if (!allSymbols.length || !characters.length) return
|
||||
// (type, region) → symbol id 매핑
|
||||
const lookup = {}
|
||||
for (const s of allSymbols) lookup[`${s.type}|${s.region}`] = s
|
||||
characters.forEach((c, idx) => {
|
||||
const q = symbolQueries[idx]
|
||||
if (!q?.data?.symbols) return
|
||||
const equippedMap = {}
|
||||
for (const es of q.data.symbols) {
|
||||
const match = lookup[`${es.type}|${es.region}`]
|
||||
if (!match) continue
|
||||
equippedMap[match.id] = {
|
||||
level: es.level,
|
||||
growth: es.growth_count,
|
||||
require_growth: es.require_growth_count,
|
||||
}
|
||||
}
|
||||
syncCharacterSymbols(c.id, equippedMap)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allSymbols, symbolQueries.map((q) => q.dataUpdatedAt).join(',')])
|
||||
|
||||
const [addName, setAddName] = useState('')
|
||||
const [addError, setAddError] = useState('')
|
||||
|
||||
const symbols = allSymbols.filter((s) => s.type === tab)
|
||||
const tabInfo = tabs.find((t) => t.key === tab)
|
||||
|
||||
const searchMutation = useMutation({
|
||||
mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`),
|
||||
onSuccess: (data) => {
|
||||
if (characters.find((c) => c.character_name === data.character_name)) {
|
||||
setAddError('이미 추가된 캐릭터입니다')
|
||||
return
|
||||
}
|
||||
setAddError('')
|
||||
setAddName('')
|
||||
addCharacter(data)
|
||||
},
|
||||
onError: (err) => setAddError(err.message || '조회 실패'),
|
||||
})
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault()
|
||||
const n = addName.trim()
|
||||
if (!n) return
|
||||
setAddError('')
|
||||
searchMutation.mutate(n)
|
||||
}
|
||||
|
||||
const progress = useSymbolStore((s) => s.progress[selectedCharId])
|
||||
const isEquipped = (symbolId) => !!progress?.[symbolId]?.equipped
|
||||
|
||||
// 현재 탭의 누적 메소 + 최종 완료일 계산
|
||||
const { totalRequiredMeso, totalArrearMeso, overallDate } = useMemo(() => {
|
||||
let req = 0, arr = 0, latest = null
|
||||
for (const s of symbols) {
|
||||
const p = progress?.[s.id]
|
||||
if (!p?.equipped) continue
|
||||
if (p.level >= s.max_level) continue
|
||||
// 체납 성장치로 만렙 도달 가능한지 확인
|
||||
let lv = p.level, g = p.growth || 0
|
||||
while (lv < s.max_level) {
|
||||
const r = s.levels?.find((l) => l.level === lv)?.required_count
|
||||
if (!r || g < r) break
|
||||
g -= r; lv += 1
|
||||
}
|
||||
const effMax = lv >= s.max_level
|
||||
|
||||
// 체납 누적 (성장치 cascade)
|
||||
let arrLv = p.level, arrG = p.growth || 0
|
||||
while (arrLv < s.max_level) {
|
||||
const lv = s.levels?.find((x) => x.level === arrLv)
|
||||
if (!lv || arrG < lv.required_count) break
|
||||
arr += lv.meso_cost
|
||||
arrG -= lv.required_count
|
||||
arrLv += 1
|
||||
}
|
||||
let remaining = 0
|
||||
let gg = p.growth || 0
|
||||
for (const l of s.levels || []) {
|
||||
if (l.level < p.level) continue
|
||||
remaining += Math.max(l.required_count - gg, 0)
|
||||
gg = Math.max(gg - l.required_count, 0)
|
||||
req += l.meso_cost
|
||||
}
|
||||
if (effMax) continue // 완료 예상일 계산에서 제외
|
||||
const { date } = computeCompletion({
|
||||
remainingSymbols: remaining,
|
||||
daily: p.daily ?? s.daily_default ?? 0,
|
||||
weeklyPerWeek: (p.weeklyCount ?? 3) * (s.weekly_default || 0),
|
||||
extra: p.extra || 0,
|
||||
dailyDone: !!p.dailyDone,
|
||||
})
|
||||
if (date && (!latest || date > latest)) latest = date
|
||||
}
|
||||
return { totalRequiredMeso: req, totalArrearMeso: arr, overallDate: latest }
|
||||
}, [symbols, progress])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10 max-w-5xl mx-auto">
|
||||
{/* 캐릭터 조회 */}
|
||||
<div
|
||||
className="rounded-2xl border p-5 space-y-4"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSearch} className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<span
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{ color: 'var(--input-icon)' }}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<circle cx="8" cy="8" r="5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M12 12L16 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={addName}
|
||||
onChange={(e) => { setAddName(e.target.value); if (addError) setAddError('') }}
|
||||
placeholder="캐릭터 닉네임으로 장착 심볼 불러오기"
|
||||
className="w-full h-12 box-border rounded-lg border pl-10 pr-4 text-base outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: 'var(--input-border)',
|
||||
color: 'var(--text-strong)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={searchMutation.isPending}
|
||||
className="shrink-0 rounded-lg disabled:opacity-50 px-6 h-12 text-base font-semibold hover:bg-[var(--btn-primary-bg-hover)]"
|
||||
style={{
|
||||
background: 'var(--btn-primary-bg)',
|
||||
color: 'var(--btn-primary-text)',
|
||||
boxShadow: 'var(--btn-primary-shadow)',
|
||||
}}
|
||||
>
|
||||
{searchMutation.isPending ? '...' : '조회'}
|
||||
</button>
|
||||
</form>
|
||||
{addError && (
|
||||
<p className="text-sm" style={{ color: 'var(--danger-text)' }}>{addError}</p>
|
||||
)}
|
||||
|
||||
{/* 캐릭터 목록 */}
|
||||
{characters.length > 0 && (
|
||||
<div className="flex items-start gap-3 overflow-x-auto pt-1">
|
||||
{characters.map((c) => (
|
||||
<CharacterCard
|
||||
key={c.id}
|
||||
char={c}
|
||||
active={c.id === selectedCharId}
|
||||
onSelect={() => selectCharacter(c.id)}
|
||||
onRemove={() => removeCharacter(c.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 심볼 타입 탭 */}
|
||||
<div className="flex gap-2">
|
||||
{tabs.map((t) => {
|
||||
const active = tab === t.key
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className="flex-1 flex items-center justify-center gap-2.5 rounded-2xl border px-4 py-3"
|
||||
style={active ? {
|
||||
background: 'var(--selected-bg)',
|
||||
borderColor: 'var(--selected-border)',
|
||||
color: 'var(--accent-bright)',
|
||||
boxShadow: 'var(--btn-primary-shadow)',
|
||||
} : {
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{t.image_url ? (
|
||||
<img src={t.image_url} alt="" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded" style={{ background: 'var(--surface-nested)' }} />
|
||||
)}
|
||||
<span className="text-base font-semibold">{t.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 심볼 카드 그리드 */}
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{symbols.map((s) => (
|
||||
<SymbolCard key={s.id} symbol={s} equipped={isEquipped(s.id)} charId={selectedCharId} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 전체 요약 */}
|
||||
<div
|
||||
className="rounded-2xl border p-6 flex items-center justify-between gap-6 flex-wrap"
|
||||
style={{
|
||||
background: 'var(--selected-bg)',
|
||||
borderColor: 'var(--selected-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="text-base" style={{ color: 'var(--text-muted)' }}>
|
||||
{tabInfo?.label} 전체 만렙 완료 예상일
|
||||
</div>
|
||||
<div
|
||||
className="text-3xl font-bold tabular-nums mt-1.5"
|
||||
style={{ color: 'var(--accent-bright)' }}
|
||||
>
|
||||
{overallDate ? formatKoreanDate(overallDate) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="text-right pr-10">
|
||||
<div className="text-base" style={{ color: 'var(--text-muted)' }}>누적 체납 메소</div>
|
||||
<Tooltip text={formatMesoKorean(totalArrearMeso)}>
|
||||
<div
|
||||
className="text-2xl font-bold tabular-nums mt-1 inline-block"
|
||||
style={{ color: 'var(--danger-text)' }}
|
||||
>
|
||||
{totalArrearMeso.toLocaleString()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="w-px h-12" style={{ background: 'var(--panel-border)' }} />
|
||||
<div className="text-right pl-10">
|
||||
<div className="text-base" style={{ color: 'var(--text-muted)' }}>남은 필요 메소</div>
|
||||
<Tooltip text={formatMesoKorean(totalRequiredMeso)}>
|
||||
<div
|
||||
className="text-2xl font-bold tabular-nums mt-1 inline-block"
|
||||
style={{ color: 'var(--warning-text-bright)' }}
|
||||
>
|
||||
{totalRequiredMeso.toLocaleString()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
frontend/src/features/symbol/SymbolAdmin.jsx
Normal file
13
frontend/src/features/symbol/SymbolAdmin.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Routes, Route } from 'react-router-dom'
|
||||
import SymbolList from './admin/SymbolList'
|
||||
import SymbolForm from './admin/SymbolForm'
|
||||
|
||||
export default function SymbolAdmin() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<SymbolList />} />
|
||||
<Route path="symbols/new" element={<SymbolForm />} />
|
||||
<Route path="symbols/:id" element={<SymbolForm />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
369
frontend/src/features/symbol/admin/SymbolForm.jsx
Normal file
369
frontend/src/features/symbol/admin/SymbolForm.jsx
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../../../api/client'
|
||||
import Select from '../../../components/Select'
|
||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: '아케인', label: '아케인' },
|
||||
{ value: '어센틱', label: '어센틱' },
|
||||
{ value: '그랜드 어센틱', label: '그랜드 어센틱' },
|
||||
]
|
||||
|
||||
const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition'
|
||||
|
||||
function formatMesoKorean(n) {
|
||||
if (!n || n <= 0) return ''
|
||||
const eok = Math.floor(n / 100_000_000)
|
||||
const man = Math.floor((n % 100_000_000) / 10_000)
|
||||
const parts = []
|
||||
if (eok) parts.push(`${eok}억`)
|
||||
if (man) parts.push(`${man.toLocaleString()}만`)
|
||||
if (!parts.length) return `${n.toLocaleString()}`
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function MesoInput({ value, onChange, ...rest }) {
|
||||
const display = value === '' || value == null ? '' : Number(String(value).replace(/[^\d]/g, '')).toLocaleString()
|
||||
const korean = formatMesoKorean(Number(String(value).replace(/[^\d]/g, '')) || 0)
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={display}
|
||||
onChange={(e) => {
|
||||
const digits = e.target.value.replace(/[^\d]/g, '')
|
||||
onChange(digits)
|
||||
}}
|
||||
className={`${inputCls} tabular-nums text-right`}
|
||||
{...rest}
|
||||
/>
|
||||
<div className="text-sm text-amber-300 mt-1 text-right tabular-nums min-h-[18px]">{korean || '\u00A0'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, error, required, children }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<label className="text-sm font-medium text-gray-300">
|
||||
{label} {required && <span className="text-red-400">*</span>}
|
||||
</label>
|
||||
{hint && <span className="text-xs text-gray-500">{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
{error && <div className="text-[11px] text-red-400">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SymbolForm() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { id } = useParams()
|
||||
const isEdit = !!id
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const [type, setType] = useState('아케인')
|
||||
const [region, setRegion] = useState('')
|
||||
const [maxLevel, setMaxLevel] = useState('')
|
||||
const [dailyDefault, setDailyDefault] = useState('')
|
||||
const [weeklyDefault, setWeeklyDefault] = useState('')
|
||||
const [imageFile, setImageFile] = useState(null)
|
||||
const [imagePreview, setImagePreview] = useState(null)
|
||||
const [existingImageUrl, setExistingImageUrl] = useState(null)
|
||||
const [levels, setLevels] = useState([])
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 편집 시 데이터 로드
|
||||
const { data: symbolData } = useQuery({
|
||||
queryKey: ['admin', 'symbol', 'symbols', id],
|
||||
queryFn: () => api(`/api/admin/symbol/symbols/${id}`),
|
||||
enabled: isEdit,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!symbolData) return
|
||||
setType(symbolData.type)
|
||||
setRegion(symbolData.region)
|
||||
setMaxLevel(String(symbolData.max_level))
|
||||
setDailyDefault(String(symbolData.daily_default ?? ''))
|
||||
setWeeklyDefault(String(symbolData.weekly_default ?? ''))
|
||||
setExistingImageUrl(symbolData.image_url)
|
||||
const rows = Array.from({ length: symbolData.max_level - 1 }, (_, i) => {
|
||||
const level = i + 1
|
||||
const existing = symbolData.levels.find((l) => l.level === level)
|
||||
return {
|
||||
level,
|
||||
required_count: existing?.required_count ?? '',
|
||||
meso_cost: existing?.meso_cost ?? '',
|
||||
}
|
||||
})
|
||||
setLevels(rows)
|
||||
}, [symbolData])
|
||||
|
||||
const handleFile = (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setImageFile(file)
|
||||
setImagePreview(URL.createObjectURL(file))
|
||||
}
|
||||
|
||||
const updateLevel = (idx, field, val) => {
|
||||
setLevels((prev) => prev.map((l, i) => (i === idx ? { ...l, [field]: val } : l)))
|
||||
}
|
||||
|
||||
const adjustLevelRows = (newMax) => {
|
||||
const n = Number(newMax)
|
||||
if (!n || n < 2) return
|
||||
setLevels((prev) => {
|
||||
const rows = Array.from({ length: n - 1 }, (_, i) => {
|
||||
const level = i + 1
|
||||
return prev.find((l) => l.level === level) || { level, required_count: '', meso_cost: '' }
|
||||
})
|
||||
return rows
|
||||
})
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('type', type)
|
||||
formData.append('region', region.trim())
|
||||
formData.append('max_level', String(maxLevel))
|
||||
formData.append('daily_default', String(Number(dailyDefault) || 0))
|
||||
formData.append('weekly_default', String(Number(weeklyDefault) || 0))
|
||||
formData.append('levels', JSON.stringify(
|
||||
levels
|
||||
.filter((l) => l.required_count !== '' || l.meso_cost !== '')
|
||||
.map((l) => ({
|
||||
level: l.level,
|
||||
required_count: Number(l.required_count) || 0,
|
||||
meso_cost: Number(l.meso_cost) || 0,
|
||||
}))
|
||||
))
|
||||
if (imageFile) formData.append('image', imageFile)
|
||||
|
||||
const adminKey = localStorage.getItem('maple-admin-key')
|
||||
const url = isEdit ? `/api/admin/symbol/symbols/${id}` : '/api/admin/symbol/symbols'
|
||||
const res = await fetch(url, {
|
||||
method: isEdit ? 'PATCH' : 'POST',
|
||||
headers: { 'x-admin-key': adminKey },
|
||||
body: formData,
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error || '저장 실패')
|
||||
return json
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'symbol', 'symbols'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['symbol', 'symbols'] })
|
||||
navigate('..')
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api(`/api/admin/symbol/symbols/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'symbol', 'symbols'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['symbol', 'symbols'] })
|
||||
navigate('..')
|
||||
},
|
||||
onError: (err) => alert(err.message),
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError('')
|
||||
if (!type) return setError('심볼 종류를 선택해주세요')
|
||||
if (!region.trim()) return setError('지역 이름을 입력해주세요')
|
||||
if (!maxLevel || Number(maxLevel) < 2) return setError('만렙을 입력해주세요')
|
||||
if (!isEdit && !imageFile) return setError('심볼 이미지를 업로드해주세요')
|
||||
saveMutation.mutate()
|
||||
}
|
||||
|
||||
const displayImage = imagePreview || existingImageUrl
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{isEdit ? '심볼 편집' : '심볼 추가'}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">심볼 정보와 레벨별 필요 개수/메소를 입력합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-6 space-y-5">
|
||||
<div className="text-sm font-semibold text-emerald-300">기본 정보</div>
|
||||
|
||||
<Field label="심볼 이미지" required={!isEdit}>
|
||||
<label className="flex items-center gap-4 rounded-xl border-2 border-dashed border-white/10 hover:border-emerald-500/40 hover:bg-emerald-500/5 bg-gray-950/50 p-4 transition cursor-pointer">
|
||||
<div className="w-32 h-32 rounded-lg bg-gray-900 border border-white/5 flex items-center justify-center overflow-hidden shrink-0">
|
||||
{displayImage ? (
|
||||
<img src={displayImage} alt="" className="w-full h-full object-contain" style={{ imageRendering: 'pixelated' }} />
|
||||
) : (
|
||||
<span className="text-5xl text-gray-700">+</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-300">
|
||||
{displayImage ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">PNG, JPG, GIF 등 → WebP로 자동 변환됩니다</p>
|
||||
{imageFile && (
|
||||
<div className="text-xs text-emerald-400 mt-2 truncate">📎 {imageFile.name}</div>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
|
||||
</label>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="심볼 종류" required>
|
||||
<Select value={type} onChange={setType} options={TYPE_OPTIONS} />
|
||||
</Field>
|
||||
<Field label="지역 이름" required hint="예: 소멸의 여로">
|
||||
<input
|
||||
type="text"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="소멸의 여로"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label="만렙" required>
|
||||
<input
|
||||
type="number"
|
||||
value={maxLevel}
|
||||
onChange={(e) => { setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }}
|
||||
className={inputCls}
|
||||
min="2"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="기본 일퀘 획득량">
|
||||
<input
|
||||
type="number"
|
||||
value={dailyDefault}
|
||||
onChange={(e) => setDailyDefault(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="기본 주간퀘 획득량">
|
||||
<input
|
||||
type="number"
|
||||
value={weeklyDefault}
|
||||
onChange={(e) => setWeeklyDefault(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레벨별 설정 */}
|
||||
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-6 space-y-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="text-sm font-semibold text-emerald-300">레벨별 필요 개수 · 메소</div>
|
||||
<div className="text-xs text-gray-500">레벨 N → N+1 업그레이드 기준 (만렙-1행)</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase border-b border-white/5">
|
||||
<th className="py-2 px-3 text-left font-medium w-20">레벨</th>
|
||||
<th className="py-2 px-3 text-left font-medium">필요 심볼 수</th>
|
||||
<th className="py-2 px-3 text-left font-medium">메소</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{levels.map((l, idx) => (
|
||||
<tr key={l.level}>
|
||||
<td className="py-1.5 px-3 text-gray-400 tabular-nums">
|
||||
Lv.<span className="text-gray-200 font-semibold">{l.level}</span>
|
||||
<span className="text-gray-600 mx-1">→</span>
|
||||
{l.level + 1}
|
||||
</td>
|
||||
<td className="py-1.5 px-3">
|
||||
<input
|
||||
type="number"
|
||||
value={l.required_count}
|
||||
onChange={(e) => updateLevel(idx, 'required_count', e.target.value)}
|
||||
className={`${inputCls} max-w-36`}
|
||||
placeholder="0"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5 px-3">
|
||||
<div className="max-w-48">
|
||||
<MesoInput
|
||||
value={l.meso_cost}
|
||||
onChange={(v) => updateLevel(idx, 'meso_cost', v)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
{isEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="rounded-lg border border-red-500/40 bg-red-500/10 hover:bg-red-500/20 text-red-300 px-4 py-2 text-sm font-medium transition"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('..')}
|
||||
className="rounded-lg border border-white/10 hover:bg-white/5 text-gray-300 px-4 py-2 text-sm transition"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={saveMutation.isPending}
|
||||
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white px-5 py-2 text-sm font-semibold shadow-lg shadow-emerald-500/20 transition"
|
||||
>
|
||||
{saveMutation.isPending ? '저장 중...' : isEdit ? '저장' : '추가'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/40 bg-red-500/10 text-red-300 text-sm px-4 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={() => { setConfirmDelete(false); deleteMutation.mutate() }}
|
||||
title="심볼 삭제"
|
||||
description={'이 심볼을 삭제하시겠습니까?\n레벨별 데이터도 함께 삭제됩니다.'}
|
||||
confirmText="삭제"
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
180
frontend/src/features/symbol/admin/SymbolList.jsx
Normal file
180
frontend/src/features/symbol/admin/SymbolList.jsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
DndContext, DragOverlay, closestCenter, PointerSensor, KeyboardSensor,
|
||||
useSensor, useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { api } from '../../../api/client'
|
||||
|
||||
const TYPE_COLOR = {
|
||||
'아케인': { text: 'text-violet-300', bg: 'bg-violet-500/15', border: 'border-violet-500/30' },
|
||||
'어센틱': { text: 'text-sky-300', bg: 'bg-sky-500/15', border: 'border-sky-500/30' },
|
||||
'그랜드 어센틱': { text: 'text-amber-300', bg: 'bg-amber-500/15', border: 'border-amber-500/30' },
|
||||
}
|
||||
|
||||
function SymbolCardContent({ symbol, dragging = false }) {
|
||||
const color = TYPE_COLOR[symbol.type] || TYPE_COLOR['아케인']
|
||||
return (
|
||||
<div className={`flex items-stretch rounded-2xl border bg-gradient-to-br from-gray-900/80 to-gray-900/40 ${
|
||||
dragging
|
||||
? 'border-emerald-500/60 shadow-2xl shadow-emerald-500/30'
|
||||
: 'border-white/5'
|
||||
}`}>
|
||||
<div className="flex items-center px-2 text-gray-700 cursor-grab active:cursor-grabbing">
|
||||
<svg width="14" height="20" viewBox="0 0 14 20" fill="currentColor">
|
||||
<circle cx="4" cy="4" r="1.5" /><circle cx="10" cy="4" r="1.5" />
|
||||
<circle cx="4" cy="10" r="1.5" /><circle cx="10" cy="10" r="1.5" />
|
||||
<circle cx="4" cy="16" r="1.5" /><circle cx="10" cy="16" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex items-start gap-3 p-4 pl-2">
|
||||
<div className="shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden">
|
||||
{symbol.image_url ? (
|
||||
<img src={symbol.image_url} alt="" className="w-12 h-12 object-contain" style={{ imageRendering: 'pixelated' }} />
|
||||
) : (
|
||||
<span className="text-gray-700 text-2xl">?</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<h3 className="font-semibold truncate">{symbol.region}</h3>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${color.text} ${color.bg} ${color.border}`}>
|
||||
{symbol.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-gray-500 tabular-nums">
|
||||
<span>만렙 {symbol.max_level}</span>
|
||||
<span>일퀘 {symbol.daily_default}</span>
|
||||
<span>주간퀘 {symbol.weekly_default}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableSymbolCard({ symbol }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging, setActivatorNodeRef } = useSortable({
|
||||
id: symbol.id,
|
||||
transition: { duration: 200, easing: 'cubic-bezier(0.25, 0.1, 0.25, 1)' },
|
||||
})
|
||||
const style = { transform: CSS.Transform.toString(transform), transition }
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={`relative ${isDragging ? 'opacity-30' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute left-0 top-0 bottom-0 w-8 z-10 cursor-grab active:cursor-grabbing rounded-l-2xl hover:bg-white/5 transition touch-none"
|
||||
aria-label="순서 변경"
|
||||
/>
|
||||
<Link to={`symbols/${symbol.id}`} className="block group hover:[&_h3]:text-emerald-300 [&_h3]:transition">
|
||||
<SymbolCardContent symbol={symbol} />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SymbolList() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: symbols = [], isLoading } = useQuery({
|
||||
queryKey: ['admin', 'symbol', 'symbols'],
|
||||
queryFn: () => api('/api/admin/symbol/symbols').catch(() => []),
|
||||
})
|
||||
|
||||
const [items, setItems] = useState([])
|
||||
const [activeId, setActiveId] = useState(null)
|
||||
useEffect(() => { setItems(symbols) }, [symbols])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (ids) => api('/api/admin/symbol/symbols/reorder', {
|
||||
method: 'POST',
|
||||
body: { ids },
|
||||
}),
|
||||
onError: (err) => {
|
||||
alert(err.message)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'symbol', 'symbols'] })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['symbol', 'symbols'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event
|
||||
setActiveId(null)
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIdx = items.findIndex((s) => s.id === active.id)
|
||||
const newIdx = items.findIndex((s) => s.id === over.id)
|
||||
const next = arrayMove(items, oldIdx, newIdx)
|
||||
setItems(next)
|
||||
reorderMutation.mutate(next.map((s) => s.id))
|
||||
}
|
||||
|
||||
const activeSymbol = items.find((s) => s.id === activeId)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">심볼 관리</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">심볼 정보 및 레벨별 필요 개수/메소를 관리합니다</p>
|
||||
</div>
|
||||
<Link
|
||||
to="symbols/new"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 px-4 py-2 text-sm font-medium transition shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
<span className="text-base leading-none">+</span>
|
||||
심볼 추가
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-24 rounded-2xl bg-white/[0.02] animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
|
||||
<div className="text-5xl mb-3 opacity-30">🔮</div>
|
||||
<p className="text-gray-400 mb-4">등록된 심볼이 없습니다</p>
|
||||
<Link to="symbols/new" className="text-sm text-emerald-400 hover:text-emerald-300 transition">
|
||||
첫 심볼 추가하기 →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={(e) => setActiveId(e.active.id)}
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={items.map((s) => s.id)} strategy={rectSortingStrategy}>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((s) => (
|
||||
<SortableSymbolCard key={s.id} symbol={s} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.25, 0.1, 0.25, 1)' }}>
|
||||
{activeSymbol ? <SymbolCardContent symbol={activeSymbol} dragging /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
frontend/src/features/symbol/store.js
Normal file
109
frontend/src/features/symbol/store.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
/**
|
||||
* 심볼 계산기 상태
|
||||
* characters: [{ id, character_name, character_level, job_name, character_image, ... }]
|
||||
* selectedCharId: 현재 선택된 캐릭터 id (ocid)
|
||||
* progress: {
|
||||
* [charId]: {
|
||||
* [symbolId]: {
|
||||
* level: number,
|
||||
* growth: number, // 현재 누적 성장치
|
||||
* daily: number, // 일퀘 획득량 (기본값 수정 가능)
|
||||
* weeklyCount: 1|2|3, // 주간퀘 횟수
|
||||
* extra: number, // 추가 심볼
|
||||
* dailyDone: boolean, // 금일 일퀘 완료 여부
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export const useSymbolStore = create(persist(
|
||||
(set, get) => ({
|
||||
characters: [],
|
||||
selectedCharId: null,
|
||||
progress: {},
|
||||
selectedTabs: {}, // { [charId]: '아케인' | '어센틱' | '그랜드 어센틱' }
|
||||
|
||||
setTab: (charId, tabKey) => set((s) => ({
|
||||
selectedTabs: { ...s.selectedTabs, [charId]: tabKey },
|
||||
})),
|
||||
|
||||
setCharacters: (next) => set((s) => ({
|
||||
characters: typeof next === 'function' ? next(s.characters) : next,
|
||||
})),
|
||||
|
||||
addCharacter: (char) => set((s) => {
|
||||
if (s.characters.find((c) => c.character_name === char.character_name)) return s
|
||||
const entry = { ...char, id: char.ocid }
|
||||
return {
|
||||
characters: [...s.characters, entry],
|
||||
selectedCharId: entry.id,
|
||||
}
|
||||
}),
|
||||
|
||||
removeCharacter: (id) => set((s) => {
|
||||
const nextProgress = { ...s.progress }
|
||||
delete nextProgress[id]
|
||||
return {
|
||||
characters: s.characters.filter((c) => c.id !== id),
|
||||
selectedCharId: s.selectedCharId === id ? null : s.selectedCharId,
|
||||
progress: nextProgress,
|
||||
}
|
||||
}),
|
||||
|
||||
selectCharacter: (id) => set({ selectedCharId: id }),
|
||||
|
||||
updateCharacter: (id, patch) => set((s) => ({
|
||||
characters: s.characters.map((c) => (c.id === id ? { ...c, ...patch } : c)),
|
||||
})),
|
||||
|
||||
getSymbolState: (charId, symbolId) => get().progress?.[charId]?.[symbolId],
|
||||
|
||||
updateSymbol: (charId, symbolId, patch) => set((s) => {
|
||||
const charProg = s.progress[charId] || {}
|
||||
const symProg = charProg[symbolId] || {}
|
||||
return {
|
||||
progress: {
|
||||
...s.progress,
|
||||
[charId]: {
|
||||
...charProg,
|
||||
[symbolId]: { ...symProg, ...patch },
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
resetCharacter: (charId) => set((s) => {
|
||||
const next = { ...s.progress }
|
||||
delete next[charId]
|
||||
return { progress: next }
|
||||
}),
|
||||
|
||||
/**
|
||||
* API 응답을 store에 반영.
|
||||
* equippedMap: { [symbolId]: { level, growth, require_growth } }
|
||||
* - API에 있는 심볼: equipped=true, level/growth 갱신 (사용자 입력값인 daily/weeklyCount/extra/dailyDone은 유지)
|
||||
* - API에 없는 심볼: equipped=false로 마킹
|
||||
*/
|
||||
syncCharacterSymbols: (charId, equippedMap) => set((s) => {
|
||||
const charProg = { ...(s.progress[charId] || {}) }
|
||||
// 기존 equipped를 false로 초기화
|
||||
for (const k of Object.keys(charProg)) {
|
||||
charProg[k] = { ...charProg[k], equipped: false }
|
||||
}
|
||||
// 새 장착 정보 병합
|
||||
for (const [sid, info] of Object.entries(equippedMap)) {
|
||||
charProg[sid] = {
|
||||
...(charProg[sid] || {}),
|
||||
equipped: true,
|
||||
level: info.level,
|
||||
growth: info.growth,
|
||||
require_growth: info.require_growth,
|
||||
}
|
||||
}
|
||||
return { progress: { ...s.progress, [charId]: charProg } }
|
||||
}),
|
||||
}),
|
||||
{ name: 'maple-symbol' },
|
||||
))
|
||||
|
|
@ -6,10 +6,299 @@
|
|||
--font-maple: "Maplestory", "Noto Sans KR", sans-serif;
|
||||
}
|
||||
|
||||
/* 테마 토큰 - dark (default) */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-from: #030712;
|
||||
--bg-via: #030712;
|
||||
--bg-to: #0f172a;
|
||||
--scrollbar-thumb: rgba(255, 255, 255, 0.12);
|
||||
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.22);
|
||||
--scrollbar-track: transparent;
|
||||
|
||||
--header-bg: rgba(3, 7, 18, 0.8);
|
||||
--header-border: rgba(255, 255, 255, 0.05);
|
||||
|
||||
--text-strong: #ffffff;
|
||||
--text-emphasis: #e5e7eb;
|
||||
--text-muted: #9ca3af;
|
||||
--text-dim: #6b7280;
|
||||
--text-slash: rgba(255, 255, 255, 0.2);
|
||||
|
||||
--toggle-bg: rgba(17, 24, 39, 0.6);
|
||||
--toggle-border: rgba(255, 255, 255, 0.1);
|
||||
--toggle-thumb-from: #e5e7eb;
|
||||
--toggle-thumb-to: #9ca3af;
|
||||
--toggle-thumb-icon: #0f172a;
|
||||
|
||||
--card-bg-from: rgba(17, 24, 39, 0.8);
|
||||
--card-bg-to: rgba(17, 24, 39, 0.4);
|
||||
--card-border: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow: 0 4px 14px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
|
||||
--icon-box-from: #1f2937;
|
||||
--icon-box-to: #111827;
|
||||
--icon-box-border: rgba(255, 255, 255, 0.05);
|
||||
|
||||
--divider-line: rgba(255, 255, 255, 0.1);
|
||||
--skeleton-bg: rgba(255, 255, 255, 0.02);
|
||||
|
||||
--empty-bg: rgba(17, 24, 39, 0.4);
|
||||
--empty-border: rgba(255, 255, 255, 0.05);
|
||||
|
||||
--accent: #10b981;
|
||||
--accent-hover-text: #6ee7b7;
|
||||
--accent-glow: rgba(16, 185, 129, 0.1);
|
||||
|
||||
--panel-bg: rgba(17, 24, 39, 0.5);
|
||||
--panel-border: rgba(255, 255, 255, 0.05);
|
||||
--panel-shadow: 0 4px 14px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
|
||||
--row-divider: rgba(255, 255, 255, 0.04);
|
||||
--row-hover-bg: rgba(255, 255, 255, 0.03);
|
||||
|
||||
--btn-bg: rgba(17, 24, 39, 0.6);
|
||||
--btn-bg-hover: #1f2937;
|
||||
--btn-border: rgba(255, 255, 255, 0.1);
|
||||
--btn-border-hover: rgba(255, 255, 255, 0.2);
|
||||
|
||||
--dot-inactive: #4b5563;
|
||||
--dot-inactive-hover: #6b7280;
|
||||
|
||||
--thumb-bg: #030712;
|
||||
--thumb-placeholder: #374151;
|
||||
|
||||
--badge-emerald-bg: #059669;
|
||||
--badge-amber-bg: #d97706;
|
||||
--badge-gray-bg: #374151;
|
||||
--badge-text: #ffffff;
|
||||
|
||||
--maintenance-text: #fbbf24;
|
||||
|
||||
--dashed-border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--surface-2: rgba(2, 6, 23, 0.6);
|
||||
--surface-3: rgba(17, 24, 39, 0.4);
|
||||
--surface-nested: #0f172a;
|
||||
|
||||
--input-bg: #020617;
|
||||
--input-border: rgba(255, 255, 255, 0.1);
|
||||
--input-border-hover: rgba(255, 255, 255, 0.2);
|
||||
--input-border-focus: rgba(16, 185, 129, 0.5);
|
||||
--input-placeholder: #6b7280;
|
||||
--input-icon: #6b7280;
|
||||
|
||||
--selected-bg: rgba(16, 185, 129, 0.08);
|
||||
--selected-border: rgba(16, 185, 129, 0.4);
|
||||
--option-selected-bg: rgba(16, 185, 129, 0.12);
|
||||
--option-selected-text: #6ee7b7;
|
||||
|
||||
--btn-primary-bg: #059669;
|
||||
--btn-primary-bg-hover: #10b981;
|
||||
--btn-primary-text: #ffffff;
|
||||
--btn-primary-shadow: 0 4px 14px rgba(16, 185, 129, 0.2);
|
||||
|
||||
--danger-text: #f87171;
|
||||
--danger-text-strong: #dc2626;
|
||||
--danger-bg-hover: rgba(239, 68, 68, 0.1);
|
||||
--warning-text: #fbbf24;
|
||||
--warning-text-bright: #fcd34d;
|
||||
--warning-text-dim: rgba(252, 211, 77, 0.4);
|
||||
|
||||
--progress-track: #0f172a;
|
||||
--progress-emerald: #10b981;
|
||||
--progress-amber: #f59e0b;
|
||||
|
||||
--accent-bright: #6ee7b7;
|
||||
--accent-muted: rgba(16, 185, 129, 0.1);
|
||||
--accent-text-on-emerald: #ecfdf5;
|
||||
|
||||
--tooltip-bg: #111827;
|
||||
--tooltip-border: rgba(255, 255, 255, 0.1);
|
||||
--tooltip-text: #e5e7eb;
|
||||
|
||||
--popup-bg: #111827;
|
||||
--popup-border: rgba(255, 255, 255, 0.1);
|
||||
--popup-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--disabled-opacity: 0.3;
|
||||
--inactive-filter: brightness(0.4);
|
||||
|
||||
--dialog-bg-from: #111827;
|
||||
--dialog-bg-to: #030712;
|
||||
--dialog-border: rgba(255, 255, 255, 0.1);
|
||||
--dialog-backdrop: rgba(0, 0, 0, 0.7);
|
||||
|
||||
--icon-danger-bg: rgba(239, 68, 68, 0.1);
|
||||
--icon-danger-border: rgba(239, 68, 68, 0.3);
|
||||
--icon-info-bg: rgba(16, 185, 129, 0.1);
|
||||
--icon-info-border: rgba(16, 185, 129, 0.3);
|
||||
|
||||
--ring-danger: rgba(239, 68, 68, 0.2);
|
||||
--ring-info: rgba(16, 185, 129, 0.2);
|
||||
|
||||
--btn-danger-bg: #dc2626;
|
||||
--btn-danger-bg-hover: #ef4444;
|
||||
--btn-danger-shadow: 0 4px 14px rgba(239, 68, 68, 0.2);
|
||||
|
||||
--liberation-primary: #a78bfa;
|
||||
--liberation-primary-bar: rgba(167, 139, 250, 0.5);
|
||||
--liberation-secondary: #fda4af;
|
||||
--liberation-secondary-bar: rgba(253, 164, 175, 0.5);
|
||||
}
|
||||
|
||||
/* 테마 토큰 - light */
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg-from: #ffffff;
|
||||
--bg-via: #ffffff;
|
||||
--bg-to: #ffffff;
|
||||
--scrollbar-thumb: #a0a0a0;
|
||||
--scrollbar-thumb-hover: #707070;
|
||||
--scrollbar-track: transparent;
|
||||
|
||||
--header-bg: rgba(255, 255, 255, 0.8);
|
||||
--header-border: rgba(0, 0, 0, 0.08);
|
||||
|
||||
--text-strong: #0f172a;
|
||||
--text-emphasis: #1f2937;
|
||||
--text-muted: #475569;
|
||||
--text-dim: #64748b;
|
||||
--text-slash: rgba(0, 0, 0, 0.2);
|
||||
|
||||
--toggle-bg: rgba(241, 245, 249, 0.9);
|
||||
--toggle-border: rgba(0, 0, 0, 0.08);
|
||||
--toggle-thumb-from: #fde68a;
|
||||
--toggle-thumb-to: #f59e0b;
|
||||
--toggle-thumb-icon: #78350f;
|
||||
|
||||
--card-bg-from: #ffffff;
|
||||
--card-bg-to: #ffffff;
|
||||
--card-border: rgba(0, 0, 0, 0.06);
|
||||
--card-shadow: 0 2px 8px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||
|
||||
--icon-box-from: #f3f4f6;
|
||||
--icon-box-to: #e5e7eb;
|
||||
--icon-box-border: rgba(0, 0, 0, 0.06);
|
||||
|
||||
--divider-line: rgba(0, 0, 0, 0.1);
|
||||
--skeleton-bg: rgba(0, 0, 0, 0.05);
|
||||
|
||||
--empty-bg: rgba(249, 250, 251, 0.9);
|
||||
--empty-border: rgba(0, 0, 0, 0.06);
|
||||
|
||||
--accent: #059669;
|
||||
--accent-hover-text: #047857;
|
||||
--accent-glow: rgba(16, 185, 129, 0.12);
|
||||
|
||||
--panel-bg: #ffffff;
|
||||
--panel-border: rgba(0, 0, 0, 0.06);
|
||||
--panel-shadow: 0 2px 8px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||
|
||||
--row-divider: rgba(0, 0, 0, 0.06);
|
||||
--row-hover-bg: rgba(0, 0, 0, 0.03);
|
||||
|
||||
--btn-bg: #ffffff;
|
||||
--btn-bg-hover: #f3f4f6;
|
||||
--btn-border: rgba(0, 0, 0, 0.08);
|
||||
--btn-border-hover: rgba(0, 0, 0, 0.15);
|
||||
|
||||
--dot-inactive: #cbd5e1;
|
||||
--dot-inactive-hover: #94a3b8;
|
||||
|
||||
--thumb-bg: #f3f4f6;
|
||||
--thumb-placeholder: #cbd5e1;
|
||||
|
||||
--badge-emerald-bg: #059669;
|
||||
--badge-amber-bg: #d97706;
|
||||
--badge-gray-bg: #475569;
|
||||
--badge-text: #ffffff;
|
||||
|
||||
--maintenance-text: #b45309;
|
||||
|
||||
--dashed-border: rgba(0, 0, 0, 0.12);
|
||||
|
||||
--surface-2: #f8fafc;
|
||||
--surface-3: #f9fafb;
|
||||
--surface-nested: #f3f4f6;
|
||||
|
||||
--input-bg: #ffffff;
|
||||
--input-border: rgba(0, 0, 0, 0.12);
|
||||
--input-border-hover: rgba(0, 0, 0, 0.22);
|
||||
--input-border-focus: rgba(5, 150, 105, 0.55);
|
||||
--input-placeholder: #9ca3af;
|
||||
--input-icon: #94a3b8;
|
||||
|
||||
--selected-bg: rgba(16, 185, 129, 0.08);
|
||||
--selected-border: rgba(5, 150, 105, 0.5);
|
||||
--option-selected-bg: rgba(16, 185, 129, 0.12);
|
||||
--option-selected-text: #047857;
|
||||
|
||||
--btn-primary-bg: #059669;
|
||||
--btn-primary-bg-hover: #047857;
|
||||
--btn-primary-text: #ffffff;
|
||||
--btn-primary-shadow: 0 4px 14px rgba(16, 185, 129, 0.25);
|
||||
|
||||
--danger-text: #dc2626;
|
||||
--danger-text-strong: #b91c1c;
|
||||
--danger-bg-hover: rgba(220, 38, 38, 0.08);
|
||||
--warning-text: #c2410c;
|
||||
--warning-text-bright: #ea580c;
|
||||
--warning-text-dim: rgba(234, 88, 12, 0.4);
|
||||
|
||||
--progress-track: #e5e7eb;
|
||||
--progress-emerald: #10b981;
|
||||
--progress-amber: #f59e0b;
|
||||
|
||||
--accent-bright: #047857;
|
||||
--accent-muted: rgba(16, 185, 129, 0.1);
|
||||
--accent-text-on-emerald: #ecfdf5;
|
||||
|
||||
--tooltip-bg: #111827;
|
||||
--tooltip-border: rgba(255, 255, 255, 0.08);
|
||||
--tooltip-text: #f3f4f6;
|
||||
|
||||
--popup-bg: #ffffff;
|
||||
--popup-border: rgba(0, 0, 0, 0.1);
|
||||
--popup-shadow: 0 10px 30px rgba(15, 23, 42, 0.15);
|
||||
|
||||
--disabled-opacity: 0.5;
|
||||
--inactive-filter: opacity(0.25);
|
||||
|
||||
--dialog-bg-from: #ffffff;
|
||||
--dialog-bg-to: #ffffff;
|
||||
--dialog-border: rgba(0, 0, 0, 0.1);
|
||||
--dialog-backdrop: rgba(15, 23, 42, 0.45);
|
||||
|
||||
--icon-danger-bg: rgba(220, 38, 38, 0.08);
|
||||
--icon-danger-border: rgba(220, 38, 38, 0.3);
|
||||
--icon-info-bg: rgba(16, 185, 129, 0.08);
|
||||
--icon-info-border: rgba(5, 150, 105, 0.4);
|
||||
|
||||
--ring-danger: rgba(220, 38, 38, 0.15);
|
||||
--ring-info: rgba(16, 185, 129, 0.18);
|
||||
|
||||
--btn-danger-bg: #dc2626;
|
||||
--btn-danger-bg-hover: #b91c1c;
|
||||
--btn-danger-shadow: 0 4px 14px rgba(220, 38, 38, 0.25);
|
||||
|
||||
--liberation-primary: #7c3aed;
|
||||
--liberation-primary-bar: rgba(124, 58, 237, 0.5);
|
||||
--liberation-secondary: #e11d48;
|
||||
--liberation-secondary-bar: rgba(225, 29, 72, 0.5);
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
min-height: 100%;
|
||||
background: linear-gradient(to bottom right, #030712, #030712, #0f172a);
|
||||
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;
|
||||
}
|
||||
|
||||
/* OverlayScrollbars body 오버레이 테마 */
|
||||
|
|
@ -22,6 +311,13 @@ html, body, #root {
|
|||
--os-padding-axis: 2px;
|
||||
}
|
||||
|
||||
/* 라이트 테마에서는 어두운 handle */
|
||||
[data-theme="light"] .os-theme-maple.os-theme-dark {
|
||||
--os-handle-bg: #a0a0a0;
|
||||
--os-handle-bg-hover: #707070;
|
||||
--os-handle-bg-active: #505050;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: "Maplestory", "Noto Sans KR", system-ui, sans-serif;
|
||||
}
|
||||
|
|
@ -59,26 +355,27 @@ input[type="number"] {
|
|||
}
|
||||
|
||||
|
||||
/* 내부 스크롤 영역만 얇은 커스텀 스크롤바 (메인 페이지 스크롤은 기본) */
|
||||
*:not(html):not(body) {
|
||||
|
||||
/* 커스텀 스크롤바 (테마별) */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
*:not(html):not(body)::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*:not(html):not(body)::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
*:not(html):not(body)::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 5px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
*:not(html):not(body)::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
*:not(html):not(body)::-webkit-scrollbar-corner {
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,23 @@ export default function Home() {
|
|||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* 메이플 공지 */}
|
||||
<NoticeWidget />
|
||||
|
||||
<div className="space-y-10 max-w-5xl mx-auto pt-6">
|
||||
{/* 구분선 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest">Utilities</span>
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||
<div
|
||||
className="h-px flex-1"
|
||||
style={{ backgroundImage: 'linear-gradient(to right, transparent, var(--divider-line), transparent)' }}
|
||||
/>
|
||||
<span
|
||||
className="text-xs uppercase tracking-widest"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
Utilities
|
||||
</span>
|
||||
<div
|
||||
className="h-px flex-1"
|
||||
style={{ backgroundImage: 'linear-gradient(to right, transparent, var(--divider-line), transparent)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 그리드 */}
|
||||
|
|
@ -26,13 +34,25 @@ export default function Home() {
|
|||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-32 rounded-2xl bg-white/[0.02] animate-pulse" />
|
||||
<div
|
||||
key={i}
|
||||
className="h-32 rounded-2xl animate-pulse"
|
||||
style={{ background: 'var(--skeleton-bg)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : menus.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-16 text-center">
|
||||
<div
|
||||
className="rounded-2xl border p-16 text-center"
|
||||
style={{ background: 'var(--empty-bg)', borderColor: 'var(--empty-border)' }}
|
||||
>
|
||||
<div className="text-5xl mb-4 opacity-50">🍁</div>
|
||||
<p className="text-gray-400">아직 등록된 기능이 없습니다</p>
|
||||
<p
|
||||
className="transition-colors duration-500"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
아직 등록된 기능이 없습니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
|
@ -40,16 +60,27 @@ export default function Home() {
|
|||
<Link
|
||||
key={menu.id}
|
||||
to={menu.url}
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-6 hover:border-emerald-500/30 transition-all duration-300"
|
||||
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))',
|
||||
boxShadow: 'var(--card-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute -top-16 -right-16 w-40 h-40 rounded-full bg-emerald-500/0 group-hover:bg-emerald-500/10 blur-3xl transition-all duration-500" />
|
||||
<div className="relative space-y-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden group-hover:scale-110 group-hover:border-emerald-500/30 transition-all duration-300">
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl border flex items-center justify-center overflow-hidden border-[var(--icon-box-border)]"
|
||||
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
|
||||
>
|
||||
<img src={menu.image?.url || '/default.png'} alt={menu.title} className="w-9 h-9 object-contain" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold group-hover:text-emerald-300 transition">{menu.title}</h2>
|
||||
<p className="text-sm text-gray-400 mt-1 leading-relaxed">{menu.description}</p>
|
||||
<h2 className="font-medium">{menu.title}</h2>
|
||||
<p
|
||||
className="text-sm mt-1 leading-relaxed"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{menu.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
@ -57,6 +88,27 @@ export default function Home() {
|
|||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="h-px flex-1"
|
||||
style={{ backgroundImage: 'linear-gradient(to right, transparent, var(--divider-line), transparent)' }}
|
||||
/>
|
||||
<span
|
||||
className="text-xs uppercase tracking-widest"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
Notices
|
||||
</span>
|
||||
<div
|
||||
className="h-px flex-1"
|
||||
style={{ backgroundImage: 'linear-gradient(to right, transparent, var(--divider-line), transparent)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 메이플 공지 */}
|
||||
<NoticeWidget />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
11
frontend/src/stores/theme.js
Normal file
11
frontend/src/stores/theme.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export const useThemeStore = create(persist(
|
||||
(set) => ({
|
||||
theme: 'dark',
|
||||
setTheme: (theme) => set({ theme }),
|
||||
toggleTheme: () => set((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })),
|
||||
}),
|
||||
{ name: 'maple-theme' },
|
||||
))
|
||||
Loading…
Add table
Reference in a new issue