Compare commits
23 commits
45d325dfbe
...
dc48f57501
| Author | SHA1 | Date | |
|---|---|---|---|
| dc48f57501 | |||
| edbaaf09aa | |||
| 3a1d8a63ac | |||
| 5368764f85 | |||
| 7fc04cf371 | |||
| be548879dc | |||
| 4720e33f26 | |||
| 18cc1855ac | |||
| a94137bd4d | |||
| bc0c2b22f0 | |||
| 57715726b8 | |||
| 0dd81b56e5 | |||
| 1646617069 | |||
| 4be648c21c | |||
| 569def6794 | |||
| 1fe3ba0d12 | |||
| f6f1e79b82 | |||
| c6ac3366cc | |||
| 4da16abc10 | |||
| 1ad25630bf | |||
| 444cf8cf85 | |||
| b423d0ac82 | |||
| 4789c56dfa |
80 changed files with 5372 additions and 2865 deletions
21
backend/models/SundayMaple.js
Normal file
21
backend/models/SundayMaple.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../lib/db.js';
|
||||||
|
|
||||||
|
export const SundayMaple = sequelize.define('SundayMaple', {
|
||||||
|
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||||
|
// 해당 주의 금요일 (KST, YYYY-MM-DD)
|
||||||
|
week_start: { type: DataTypes.DATEONLY, allowNull: false, unique: true },
|
||||||
|
variant: {
|
||||||
|
type: DataTypes.ENUM('normal', 'special'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'normal',
|
||||||
|
},
|
||||||
|
event_post_id: { type: DataTypes.STRING(50) },
|
||||||
|
event_post_url: { type: DataTypes.STRING(500) },
|
||||||
|
source_image_url: { type: DataTypes.STRING(500) },
|
||||||
|
image_url: { type: DataTypes.STRING(500), allowNull: false },
|
||||||
|
fetched_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
|
||||||
|
}, {
|
||||||
|
tableName: 'sunday_maple',
|
||||||
|
underscored: true,
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Image } from './Image.js';
|
import { Image } from './Image.js';
|
||||||
import { Menu } from './Menu.js';
|
import { Menu } from './Menu.js';
|
||||||
|
import { SundayMaple } from './SundayMaple.js';
|
||||||
import { BossCrystalBoss } from './boss-crystal/Boss.js';
|
import { BossCrystalBoss } from './boss-crystal/Boss.js';
|
||||||
import { BossCrystalBossDifficulty } from './boss-crystal/BossDifficulty.js';
|
import { BossCrystalBossDifficulty } from './boss-crystal/BossDifficulty.js';
|
||||||
import { Symbol } from './symbol/Symbol.js';
|
import { Symbol } from './symbol/Symbol.js';
|
||||||
|
|
@ -24,4 +25,4 @@ Symbol.hasMany(SymbolLevel, {
|
||||||
});
|
});
|
||||||
SymbolLevel.belongsTo(Symbol, { foreignKey: 'symbol_id', as: 'symbol' });
|
SymbolLevel.belongsTo(Symbol, { foreignKey: 'symbol_id', as: 'symbol' });
|
||||||
|
|
||||||
export { Image, Menu, BossCrystalBoss, BossCrystalBossDifficulty, Symbol, SymbolLevel };
|
export { Image, Menu, SundayMaple, BossCrystalBoss, BossCrystalBossDifficulty, Symbol, SymbolLevel };
|
||||||
|
|
|
||||||
17
backend/package-lock.json
generated
17
backend/package-lock.json
generated
|
|
@ -11,10 +11,12 @@
|
||||||
"@aws-sdk/client-s3": "^3.800.0",
|
"@aws-sdk/client-s3": "^3.800.0",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"mariadb": "^3.4.0",
|
"mariadb": "^3.4.0",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
"mysql2": "^3.14.1",
|
"mysql2": "^3.14.1",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.5",
|
||||||
"sharp": "^0.34.1"
|
"sharp": "^0.34.1"
|
||||||
}
|
}
|
||||||
|
|
@ -2314,6 +2316,12 @@
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
|
||||||
|
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -3051,6 +3059,15 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-cron": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,16 @@
|
||||||
"dev": "node --watch server.js"
|
"dev": "node --watch server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.1.0",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"sequelize": "^6.37.5",
|
|
||||||
"mysql2": "^3.14.1",
|
|
||||||
"mariadb": "^3.4.0",
|
|
||||||
"@aws-sdk/client-s3": "^3.800.0",
|
"@aws-sdk/client-s3": "^3.800.0",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"mariadb": "^3.4.0",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
|
"mysql2": "^3.14.1",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
|
"sequelize": "^6.37.5",
|
||||||
"sharp": "^0.34.1"
|
"sharp": "^0.34.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,16 +46,53 @@ router.get('/search', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// OCID로 장착 심볼 조회
|
// 이벤트 스킬(보약) 효과에서 일퀘 심볼 보너스 파싱
|
||||||
|
// Nexon API의 skill_level 필드는 이벤트 스킬에 한해 실제 레벨이 아닌 1로 고정되므로
|
||||||
|
// skill_effect 문자열의 심볼 증가 개수를 이용해 실제 레벨을 역산한다.
|
||||||
|
// (이벤트마다 테이블이 달라지면 아래 맵을 갱신해야 함)
|
||||||
|
const ARCANE_SYMBOL_TO_LEVEL = { 2: 1, 4: 2, 8: 3, 12: 4, 16: 5, 20: 6 };
|
||||||
|
const AUTHENTIC_SYMBOL_TO_LEVEL = { 2: 1, 3: 2, 4: 3, 5: 4, 7: 5, 9: 6 };
|
||||||
|
|
||||||
|
function parseEventSkillBonus(skills) {
|
||||||
|
for (const s of skills || []) {
|
||||||
|
const eff = s.skill_effect || '';
|
||||||
|
const arcane = eff.match(/아케인리버\s*일일퀘스트[^\r\n]*?획득\s*심볼\s*(\d+)\s*개/);
|
||||||
|
const authentic = eff.match(/그란디스\s*일일퀘스트[^\r\n]*?획득\s*심볼\s*(\d+)\s*개/);
|
||||||
|
if (arcane || authentic) {
|
||||||
|
const arcaneDaily = arcane ? Number(arcane[1]) || 0 : 0;
|
||||||
|
const authenticDaily = authentic ? Number(authentic[1]) || 0 : 0;
|
||||||
|
const derivedLevel =
|
||||||
|
AUTHENTIC_SYMBOL_TO_LEVEL[authenticDaily] ||
|
||||||
|
ARCANE_SYMBOL_TO_LEVEL[arcaneDaily] ||
|
||||||
|
0;
|
||||||
|
return {
|
||||||
|
skill_name: s.skill_name,
|
||||||
|
skill_level: derivedLevel,
|
||||||
|
arcane_daily: arcaneDaily,
|
||||||
|
authentic_daily: authenticDaily,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OCID로 장착 심볼 + 이벤트 스킬 보너스 조회
|
||||||
router.get('/symbols', async (req, res) => {
|
router.get('/symbols', async (req, res) => {
|
||||||
const { ocid } = req.query;
|
const { ocid } = req.query;
|
||||||
if (!ocid) return res.status(400).json({ error: 'ocid가 필요합니다' });
|
if (!ocid) return res.status(400).json({ error: 'ocid가 필요합니다' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/symbol-equipment`, {
|
const [symbolRes, skillRes] = await Promise.all([
|
||||||
params: { ocid },
|
axios.get(`${NEXON_API_BASE}/maplestory/v1/character/symbol-equipment`, {
|
||||||
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
|
params: { ocid },
|
||||||
});
|
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
|
||||||
|
}),
|
||||||
|
axios.get(`${NEXON_API_BASE}/maplestory/v1/character/skill`, {
|
||||||
|
params: { ocid, character_skill_grade: '0' },
|
||||||
|
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
|
||||||
|
}).catch(() => ({ data: { character_skill: [] } })),
|
||||||
|
]);
|
||||||
|
const data = symbolRes.data;
|
||||||
|
|
||||||
const parsed = (data.symbol || []).map((s) => {
|
const parsed = (data.symbol || []).map((s) => {
|
||||||
const [prefix, region] = (s.symbol_name || '').split(' : ').map((t) => t.trim());
|
const [prefix, region] = (s.symbol_name || '').split(' : ').map((t) => t.trim());
|
||||||
|
|
@ -70,7 +107,9 @@ router.get('/symbols', async (req, res) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ ocid, character_class: data.character_class, symbols: parsed });
|
const event_skill = parseEventSkillBonus(skillRes.data?.character_skill);
|
||||||
|
|
||||||
|
res.json({ ocid, character_class: data.character_class, symbols: parsed, event_skill });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err.response?.data?.error?.name;
|
const code = err.response?.data?.error?.name;
|
||||||
if (['OPENAPI00001', 'OPENAPI00007', 'OPENAPI00010', 'OPENAPI00011'].includes(code)) {
|
if (['OPENAPI00001', 'OPENAPI00007', 'OPENAPI00010', 'OPENAPI00011'].includes(code)) {
|
||||||
|
|
|
||||||
45
backend/routes/sunday-maple.js
Normal file
45
backend/routes/sunday-maple.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { SundayMaple } from '../models/index.js';
|
||||||
|
import {
|
||||||
|
currentWeekFriday,
|
||||||
|
isInSundayWindow,
|
||||||
|
fetchAndSaveSundayMaple,
|
||||||
|
} from '../services/sundayMaple.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 이번 주 썬데이 메이플 조회 (금~일만 available)
|
||||||
|
router.get('/current', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isInSundayWindow()) {
|
||||||
|
return res.json({ available: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekStart = currentWeekFriday();
|
||||||
|
let row = await SundayMaple.findOne({ where: { week_start: weekStart } });
|
||||||
|
|
||||||
|
// DB에 없으면 lazy fetch 시도 (cron이 놓친 경우 대비)
|
||||||
|
if (!row) {
|
||||||
|
try {
|
||||||
|
row = await fetchAndSaveSundayMaple();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sunday-maple] lazy fetch 실패:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) return res.json({ available: false });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
available: true,
|
||||||
|
variant: row.variant,
|
||||||
|
week_start: row.week_start,
|
||||||
|
image_url: row.image_url,
|
||||||
|
event_post_url: row.event_post_url,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sunday-maple/current] 오류:', err);
|
||||||
|
res.status(500).json({ error: '조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -7,8 +7,10 @@ import bossCrystalRoutes from './routes/boss-crystal.js';
|
||||||
import characterRoutes from './routes/character.js';
|
import characterRoutes from './routes/character.js';
|
||||||
import imageRoutes from './routes/images.js';
|
import imageRoutes from './routes/images.js';
|
||||||
import symbolRoutes from './routes/symbol.js';
|
import symbolRoutes from './routes/symbol.js';
|
||||||
|
import sundayMapleRoutes from './routes/sunday-maple.js';
|
||||||
import { sequelize } from './lib/db.js';
|
import { sequelize } from './lib/db.js';
|
||||||
import './models/index.js';
|
import './models/index.js';
|
||||||
|
import { scheduleSundayMapleCron } from './services/sundayMapleCron.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
@ -27,6 +29,7 @@ app.use('/api/boss-crystal', bossCrystalRoutes);
|
||||||
app.use('/api/character', characterRoutes);
|
app.use('/api/character', characterRoutes);
|
||||||
app.use('/api/images', imageRoutes);
|
app.use('/api/images', imageRoutes);
|
||||||
app.use('/api/symbols', symbolRoutes);
|
app.use('/api/symbols', symbolRoutes);
|
||||||
|
app.use('/api/sunday-maple', sundayMapleRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
|
|
@ -40,6 +43,8 @@ async function start() {
|
||||||
await sequelize.sync();
|
await sequelize.sync();
|
||||||
console.log('테이블 동기화 완료');
|
console.log('테이블 동기화 완료');
|
||||||
|
|
||||||
|
scheduleSundayMapleCron();
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`서버 시작: http://localhost:${PORT}`);
|
console.log(`서버 시작: http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
132
backend/services/sundayMaple.js
Normal file
132
backend/services/sundayMaple.js
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import utc from 'dayjs/plugin/utc.js';
|
||||||
|
import timezone from 'dayjs/plugin/timezone.js';
|
||||||
|
import { SundayMaple } from '../models/index.js';
|
||||||
|
import { uploadObject, getPublicUrl } from '../lib/s3.js';
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
const KST = 'Asia/Seoul';
|
||||||
|
|
||||||
|
const NEXON_API_BASE = 'https://open.api.nexon.com';
|
||||||
|
const RUSTFS_PREFIX = 'sunday';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제목을 파싱하여 썬데이 메이플 변형을 반환
|
||||||
|
* @returns {'normal'|'special'|null}
|
||||||
|
*/
|
||||||
|
export function detectVariant(title) {
|
||||||
|
if (!title) return null;
|
||||||
|
const t = title.trim();
|
||||||
|
if (!t.includes('썬데이') || !t.includes('메이플')) return null;
|
||||||
|
return t.includes('스페셜') ? 'special' : 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 금요일 기준의 이번 주차 시작일 (YYYY-MM-DD, KST) 반환
|
||||||
|
* 금/토/일 → 직전 금요일
|
||||||
|
* 월/화/수/목 → 이전 주 금요일
|
||||||
|
*/
|
||||||
|
export function currentWeekFriday(now = dayjs().tz(KST)) {
|
||||||
|
const dow = now.day(); // 0=일 ... 5=금 6=토
|
||||||
|
// 금요일 기준 diff: 금(5)이면 0, 토(6)이면 -1, 일(0)이면 -2, 월(1)이면 -3 ...
|
||||||
|
const diff = dow >= 5 ? dow - 5 : dow + 2;
|
||||||
|
return now.startOf('day').subtract(diff, 'day').format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 금~일요일인지
|
||||||
|
*/
|
||||||
|
export function isInSundayWindow(now = dayjs().tz(KST)) {
|
||||||
|
const dow = now.day();
|
||||||
|
return dow === 5 || dow === 6 || dow === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nexon 이벤트 API에서 이번 주 썬데이 메이플 항목 찾기
|
||||||
|
*/
|
||||||
|
async function findSundayPost() {
|
||||||
|
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/notice-event`, {
|
||||||
|
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
|
||||||
|
});
|
||||||
|
const list = data.event_notice || [];
|
||||||
|
const weekStart = currentWeekFriday();
|
||||||
|
// 제목 매칭 + 이번 주 시작
|
||||||
|
return list.find((n) => {
|
||||||
|
if (detectVariant(n.title) === null) return null;
|
||||||
|
const start = n.date_event_start ? dayjs(n.date_event_start).tz(KST).format('YYYY-MM-DD') : null;
|
||||||
|
// date_event_start가 금요일 아닐 수 있음 → 시작일이 해당 주 금요일과 같은 주인지 체크
|
||||||
|
if (!start) return false;
|
||||||
|
const sameWeek = dayjs(start).tz(KST).isSame(dayjs(weekStart).tz(KST), 'week')
|
||||||
|
// dayjs isSame 'week' 는 일요일 기준이라 금/토 확인 필요 → 직접 비교
|
||||||
|
|| Math.abs(dayjs(start).diff(dayjs(weekStart), 'day')) <= 2;
|
||||||
|
return sameWeek;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시글 HTML에서 첫 번째 컨텐츠 PNG URL 추출
|
||||||
|
*/
|
||||||
|
async function scrapeImageUrl(postUrl) {
|
||||||
|
const { data: html } = await axios.get(postUrl, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||||
|
});
|
||||||
|
const re = /<img[^>]+src=["'](https:\/\/lwi\.nexon\.com\/maplestory\/\d{4}\/\d{4}_board\/[^"']+\.png)["']/i;
|
||||||
|
const m = html.match(re);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nexon CDN에서 이미지 다운로드 → rustfs 업로드 → URL 반환
|
||||||
|
*/
|
||||||
|
async function downloadAndUpload(sourceUrl, weekStart) {
|
||||||
|
const { data: buffer } = await axios.get(sourceUrl, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||||
|
});
|
||||||
|
const key = `${RUSTFS_PREFIX}/${weekStart}.png`;
|
||||||
|
await uploadObject(key, Buffer.from(buffer), 'image/png');
|
||||||
|
return { key, url: getPublicUrl(key) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 핵심: 이번 주 썬데이 메이플을 Nexon에서 찾아 rustfs에 업로드하고 DB 저장.
|
||||||
|
* 이미 DB에 있으면 그대로 반환 (noop).
|
||||||
|
*
|
||||||
|
* @returns SundayMaple | null (게시글 아직 없음)
|
||||||
|
*/
|
||||||
|
export async function fetchAndSaveSundayMaple() {
|
||||||
|
const weekStart = currentWeekFriday();
|
||||||
|
|
||||||
|
// 이미 저장되어 있으면 스킵
|
||||||
|
const existing = await SundayMaple.findOne({ where: { week_start: weekStart } });
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const post = await findSundayPost();
|
||||||
|
if (!post) return null;
|
||||||
|
|
||||||
|
const variant = detectVariant(post.title);
|
||||||
|
const sourceUrl = await scrapeImageUrl(post.url);
|
||||||
|
if (!sourceUrl) {
|
||||||
|
console.warn('[sunday-maple] 이미지 URL 못찾음:', post.url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url: uploadedUrl } = await downloadAndUpload(sourceUrl, weekStart);
|
||||||
|
|
||||||
|
// 업로드 완료 후 DB insert (동시성 대비 findOrCreate)
|
||||||
|
const [row] = await SundayMaple.findOrCreate({
|
||||||
|
where: { week_start: weekStart },
|
||||||
|
defaults: {
|
||||||
|
week_start: weekStart,
|
||||||
|
variant,
|
||||||
|
event_post_id: String(post.notice_id || ''),
|
||||||
|
event_post_url: post.url,
|
||||||
|
source_image_url: sourceUrl,
|
||||||
|
image_url: uploadedUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('[sunday-maple] 저장 완료:', weekStart, variant, uploadedUrl);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
35
backend/services/sundayMapleCron.js
Normal file
35
backend/services/sundayMapleCron.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import cron from 'node-cron';
|
||||||
|
import { fetchAndSaveSundayMaple } from './sundayMaple.js';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 10_000; // 10초 간격
|
||||||
|
const MAX_DURATION_MS = 5 * 60 * 1000; // 최대 5분
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 금요일 9시부터 10초 간격 폴링 → 찾으면 저장 후 종료, 5분 타임아웃.
|
||||||
|
*/
|
||||||
|
async function runPolling() {
|
||||||
|
const started = Date.now();
|
||||||
|
console.log('[sunday-maple cron] 폴링 시작');
|
||||||
|
|
||||||
|
while (Date.now() - started < MAX_DURATION_MS) {
|
||||||
|
try {
|
||||||
|
const row = await fetchAndSaveSundayMaple();
|
||||||
|
if (row) {
|
||||||
|
console.log('[sunday-maple cron] 저장 완료 → 종료');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[sunday-maple cron] 폴링 중 오류:', err.message);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
console.log('[sunday-maple cron] 5분 타임아웃 → 종료 (lazy fallback이 커버)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매주 금요일 09:00 KST 실행
|
||||||
|
*/
|
||||||
|
export function scheduleSundayMapleCron() {
|
||||||
|
cron.schedule('0 9 * * 5', runPolling, { timezone: 'Asia/Seoul' });
|
||||||
|
console.log('[sunday-maple cron] 매주 금요일 09:00 KST 스케줄 등록');
|
||||||
|
}
|
||||||
1515
frontend/package-lock.json
generated
1515
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,7 +7,9 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -19,6 +21,7 @@
|
||||||
"overlayscrollbars": "^2.15.1",
|
"overlayscrollbars": "^2.15.1",
|
||||||
"overlayscrollbars-react": "^0.5.6",
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
|
|
@ -34,6 +37,7 @@
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"vite": "^8.0.4"
|
"vite": "^8.0.4",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,20 @@
|
||||||
import { Routes, Route } from 'react-router-dom'
|
import { isMobileOnly, isTablet } from 'react-device-detect'
|
||||||
import Layout from './components/Layout'
|
import PCRoutes from './routes/pc'
|
||||||
import Home from './pages/Home'
|
import MobileRoutes from './routes/mobile'
|
||||||
import FeaturePage from './features/FeaturePage'
|
import TabletRoutes from './routes/tablet'
|
||||||
import AdminLayout from './features/admin/AdminLayout'
|
import GlobalTooltip from './components/common/GlobalTooltip'
|
||||||
import AdminHome from './features/admin/AdminHome'
|
|
||||||
import AdminImages from './features/admin/AdminImages'
|
function Routes() {
|
||||||
import AdminMenuForm from './features/admin/AdminMenuForm'
|
if (isMobileOnly) return <MobileRoutes />
|
||||||
import AdminFeaturePage from './features/admin/AdminFeaturePage'
|
if (isTablet) return <TabletRoutes />
|
||||||
|
return <PCRoutes />
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<>
|
||||||
<Route element={<Layout />}>
|
<Routes />
|
||||||
<Route index element={<Home />} />
|
<GlobalTooltip />
|
||||||
|
</>
|
||||||
{/* 관리자 */}
|
|
||||||
<Route path="/admin" element={<AdminLayout />}>
|
|
||||||
<Route index element={<AdminHome />} />
|
|
||||||
<Route path="images" element={<AdminImages />} />
|
|
||||||
<Route path="menus/new" element={<AdminMenuForm />} />
|
|
||||||
<Route path="menus/:id" element={<AdminMenuForm />} />
|
|
||||||
<Route path=":slug/*" element={<AdminFeaturePage />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* 동적 기능 페이지 */}
|
|
||||||
<Route path="/:slug/*" element={<FeaturePage />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,431 +0,0 @@
|
||||||
import { useState } from 'react'
|
|
||||||
import { useQueries } from '@tanstack/react-query'
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
import { api } from '../api/client'
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtMD(iso) {
|
|
||||||
if (!iso) return ''
|
|
||||||
const d = new Date(iso)
|
|
||||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
|
||||||
}
|
|
||||||
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 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Text List Section ─────────────────────────────────────── */
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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={item.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="group relative block rounded-xl overflow-hidden border"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
boxShadow: 'var(--panel-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="aspect-[2/1] overflow-hidden"
|
|
||||||
style={{ background: 'var(--thumb-bg)' }}
|
|
||||||
>
|
|
||||||
{item.thumbnail_url ? (
|
|
||||||
<img
|
|
||||||
src={item.thumbnail_url}
|
|
||||||
alt=""
|
|
||||||
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="text-xs tabular-nums"
|
|
||||||
style={{ color: 'var(--text-dim)' }}
|
|
||||||
>
|
|
||||||
{dateText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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="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
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
disabled={clamped === 0}
|
|
||||||
className={navBtn}
|
|
||||||
aria-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>
|
|
||||||
<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="relative overflow-x-clip pb-2">
|
|
||||||
{isLoading ? (
|
|
||||||
<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>
|
|
||||||
) : 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>
|
|
||||||
) : (
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{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,113 +0,0 @@
|
||||||
import { useState, useRef, useEffect, useLayoutEffect } from 'react'
|
|
||||||
import { createPortal } from 'react-dom'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 커스텀 툴팁
|
|
||||||
* <Tooltip text="설명">
|
|
||||||
* <button>...</button>
|
|
||||||
* </Tooltip>
|
|
||||||
*/
|
|
||||||
export default function Tooltip({ text, children, placement = 'top', delay = 200 }) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [coords, setCoords] = useState(null) // null이면 위치 측정 전 (안 보임)
|
|
||||||
const triggerRef = useRef(null)
|
|
||||||
const tooltipRef = useRef(null)
|
|
||||||
const timerRef = useRef(null)
|
|
||||||
|
|
||||||
const updatePosition = () => {
|
|
||||||
if (!triggerRef.current || !tooltipRef.current) return
|
|
||||||
const trigger = triggerRef.current.getBoundingClientRect()
|
|
||||||
const tooltip = tooltipRef.current.getBoundingClientRect()
|
|
||||||
const gap = 6
|
|
||||||
|
|
||||||
let top, left
|
|
||||||
switch (placement) {
|
|
||||||
case 'bottom':
|
|
||||||
top = trigger.bottom + gap
|
|
||||||
left = trigger.left + trigger.width / 2 - tooltip.width / 2
|
|
||||||
break
|
|
||||||
case 'left':
|
|
||||||
top = trigger.top + trigger.height / 2 - tooltip.height / 2
|
|
||||||
left = trigger.left - tooltip.width - gap
|
|
||||||
break
|
|
||||||
case 'right':
|
|
||||||
top = trigger.top + trigger.height / 2 - tooltip.height / 2
|
|
||||||
left = trigger.right + gap
|
|
||||||
break
|
|
||||||
case 'top':
|
|
||||||
default:
|
|
||||||
top = trigger.top - tooltip.height - gap
|
|
||||||
left = trigger.left + trigger.width / 2 - tooltip.width / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
const padding = 4
|
|
||||||
left = Math.max(padding, Math.min(left, window.innerWidth - tooltip.width - padding))
|
|
||||||
top = Math.max(padding, Math.min(top, window.innerHeight - tooltip.height - padding))
|
|
||||||
|
|
||||||
setCoords({ top, left })
|
|
||||||
}
|
|
||||||
|
|
||||||
// open 상태가 true가 되면 즉시 위치 측정 (paint 전에)
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (open) updatePosition()
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
const handler = () => updatePosition()
|
|
||||||
window.addEventListener('scroll', handler, true)
|
|
||||||
window.addEventListener('resize', handler)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', handler, true)
|
|
||||||
window.removeEventListener('resize', handler)
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const handleEnter = () => {
|
|
||||||
timerRef.current = setTimeout(() => setOpen(true), delay)
|
|
||||||
}
|
|
||||||
const handleLeave = () => {
|
|
||||||
clearTimeout(timerRef.current)
|
|
||||||
setOpen(false)
|
|
||||||
setCoords(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!text) return children
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
ref={triggerRef}
|
|
||||||
onMouseEnter={handleEnter}
|
|
||||||
onMouseLeave={handleLeave}
|
|
||||||
onFocus={handleEnter}
|
|
||||||
onBlur={handleLeave}
|
|
||||||
className="inline-block"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
{open && createPortal(
|
|
||||||
<div
|
|
||||||
ref={tooltipRef}
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: coords?.top ?? 0,
|
|
||||||
left: coords?.left ?? 0,
|
|
||||||
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 border text-xs shadow-lg whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { api } from '../api/client'
|
import { api } from '../../api/client'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐릭터 입력 input 아래 뜨는 드롭다운
|
* 캐릭터 입력 input 아래 뜨는 드롭다운
|
||||||
|
|
@ -15,7 +15,7 @@ export default function Checkbox({ checked, onChange, disabled, className = '',
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
onClick={(e) => { e.stopPropagation(); !disabled && onChange?.(!checked) }}
|
onClick={(e) => { e.stopPropagation(); !disabled && onChange?.(!checked) }}
|
||||||
className={`${sizeCls} shrink-0 rounded-md border-2 flex items-center justify-center ${
|
className={`${sizeCls} shrink-0 rounded-md border-2 flex items-center justify-center ${
|
||||||
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'
|
disabled ? 'opacity-40' : 'cursor-pointer'
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
style={checked ? {
|
style={checked ? {
|
||||||
borderColor: 'var(--accent)',
|
borderColor: 'var(--accent)',
|
||||||
|
|
@ -135,7 +135,7 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => stop(e, viewMode === 'years' ? prevYearRange : prevMonth)}
|
onClick={(e) => stop(e, viewMode === 'years' ? prevYearRange : prevMonth)}
|
||||||
disabled={viewMode === 'years' ? !canGoPrevYearRange : (year === minYear && month === 0)}
|
disabled={viewMode === 'years' ? !canGoPrevYearRange : (year === minYear && month === 0)}
|
||||||
className="p-1.5 rounded hover:bg-[var(--row-hover-bg)] disabled:opacity-30 disabled:cursor-not-allowed"
|
className="p-1.5 rounded hover:bg-[var(--row-hover-bg)] disabled:opacity-30"
|
||||||
style={{ color: 'var(--text-muted)' }}
|
style={{ color: 'var(--text-muted)' }}
|
||||||
>
|
>
|
||||||
<ChevronIcon dir="left" size={18} />
|
<ChevronIcon dir="left" size={18} />
|
||||||
31
frontend/src/components/common/FormField.jsx
Normal file
31
frontend/src/components/common/FormField.jsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* 관리자 폼 공용 필드 래퍼
|
||||||
|
* <FormField label="제목" required hint="설명" error={errors.title}>
|
||||||
|
* <input ... />
|
||||||
|
* </FormField>
|
||||||
|
*/
|
||||||
|
export default function FormField({ 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" style={{ color: 'var(--text-emphasis)' }}>
|
||||||
|
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
|
||||||
|
</label>
|
||||||
|
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 폼 공용 input 스타일
|
||||||
|
*/
|
||||||
|
export const formInputClass = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
|
||||||
|
|
||||||
|
export const formInputStyle = {
|
||||||
|
background: 'var(--input-bg)',
|
||||||
|
borderColor: 'var(--input-border)',
|
||||||
|
color: 'var(--text-strong)',
|
||||||
|
}
|
||||||
158
frontend/src/components/common/GlobalTooltip.jsx
Normal file
158
frontend/src/components/common/GlobalTooltip.jsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { useEffect, useRef, useState, useLayoutEffect } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
const DELAY_DEFAULT = 200
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱 루트에 한 번 마운트되는 전역 툴팁.
|
||||||
|
* document에 이벤트 위임으로 [title] 또는 [data-tooltip] 를 가진 요소를 감지.
|
||||||
|
*
|
||||||
|
* 사용 측은 그냥 `title="설명"` 만 붙이면 됨:
|
||||||
|
* <button title="삭제">×</button>
|
||||||
|
*
|
||||||
|
* 커스텀 옵션:
|
||||||
|
* data-tooltip="..." title 대신 사용 (native tooltip 피하고 싶을 때)
|
||||||
|
* data-tooltip-placement="top" top | bottom | left | right (기본 top)
|
||||||
|
* data-tooltip-delay="300" ms (기본 200)
|
||||||
|
*/
|
||||||
|
export default function GlobalTooltip() {
|
||||||
|
const [state, setState] = useState({ open: false, text: '', placement: 'top', coords: null })
|
||||||
|
const triggerRef = useRef(null)
|
||||||
|
const tooltipRef = useRef(null)
|
||||||
|
const timerRef = useRef(null)
|
||||||
|
const titleMap = useRef(new WeakMap())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function restoreTitle() {
|
||||||
|
const el = triggerRef.current
|
||||||
|
if (el && titleMap.current.has(el)) {
|
||||||
|
const prev = titleMap.current.get(el)
|
||||||
|
el.setAttribute('title', prev)
|
||||||
|
titleMap.current.delete(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
restoreTitle()
|
||||||
|
triggerRef.current = null
|
||||||
|
setState((s) => (s.open ? { ...s, open: false } : s))
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFor(target) {
|
||||||
|
const nativeTitle = target.getAttribute('title')
|
||||||
|
const dataTooltip = target.getAttribute('data-tooltip')
|
||||||
|
const text = nativeTitle || dataTooltip
|
||||||
|
if (!text) return
|
||||||
|
if (nativeTitle && !titleMap.current.has(target)) {
|
||||||
|
titleMap.current.set(target, nativeTitle)
|
||||||
|
target.removeAttribute('title')
|
||||||
|
}
|
||||||
|
const placement = target.getAttribute('data-tooltip-placement') || 'top'
|
||||||
|
const delay = Number(target.getAttribute('data-tooltip-delay')) || DELAY_DEFAULT
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
triggerRef.current = target
|
||||||
|
setState({ open: true, text, placement, coords: null })
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOver(e) {
|
||||||
|
const target = e.target.closest?.('[title], [data-tooltip]')
|
||||||
|
if (!target) return
|
||||||
|
if (triggerRef.current === target) return
|
||||||
|
// 다른 타겟으로 이동 시 기존 정리
|
||||||
|
if (triggerRef.current && triggerRef.current !== target) {
|
||||||
|
restoreTitle()
|
||||||
|
triggerRef.current = null
|
||||||
|
}
|
||||||
|
showFor(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOut(e) {
|
||||||
|
if (!triggerRef.current) return
|
||||||
|
const rt = e.relatedTarget
|
||||||
|
if (rt && triggerRef.current.contains(rt)) return
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDown() { hide() }
|
||||||
|
function handleKey(e) { if (e.key === 'Escape') hide() }
|
||||||
|
|
||||||
|
document.addEventListener('mouseover', handleOver, true)
|
||||||
|
document.addEventListener('mouseout', handleOut, true)
|
||||||
|
document.addEventListener('focusin', handleOver, true)
|
||||||
|
document.addEventListener('focusout', handleOut, true)
|
||||||
|
document.addEventListener('mousedown', handleDown, true)
|
||||||
|
document.addEventListener('keydown', handleKey, true)
|
||||||
|
window.addEventListener('scroll', hide, true)
|
||||||
|
window.addEventListener('resize', hide)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mouseover', handleOver, true)
|
||||||
|
document.removeEventListener('mouseout', handleOut, true)
|
||||||
|
document.removeEventListener('focusin', handleOver, true)
|
||||||
|
document.removeEventListener('focusout', handleOut, true)
|
||||||
|
document.removeEventListener('mousedown', handleDown, true)
|
||||||
|
document.removeEventListener('keydown', handleKey, true)
|
||||||
|
window.removeEventListener('scroll', hide, true)
|
||||||
|
window.removeEventListener('resize', hide)
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
restoreTitle()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// open 되면 위치 측정
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!state.open || !triggerRef.current || !tooltipRef.current) return
|
||||||
|
const trigger = triggerRef.current.getBoundingClientRect()
|
||||||
|
const tooltip = tooltipRef.current.getBoundingClientRect()
|
||||||
|
const gap = 6
|
||||||
|
let top, left
|
||||||
|
switch (state.placement) {
|
||||||
|
case 'bottom':
|
||||||
|
top = trigger.bottom + gap
|
||||||
|
left = trigger.left + trigger.width / 2 - tooltip.width / 2
|
||||||
|
break
|
||||||
|
case 'left':
|
||||||
|
top = trigger.top + trigger.height / 2 - tooltip.height / 2
|
||||||
|
left = trigger.left - tooltip.width - gap
|
||||||
|
break
|
||||||
|
case 'right':
|
||||||
|
top = trigger.top + trigger.height / 2 - tooltip.height / 2
|
||||||
|
left = trigger.right + gap
|
||||||
|
break
|
||||||
|
case 'top':
|
||||||
|
default:
|
||||||
|
top = trigger.top - tooltip.height - gap
|
||||||
|
left = trigger.left + trigger.width / 2 - tooltip.width / 2
|
||||||
|
}
|
||||||
|
const padding = 4
|
||||||
|
left = Math.max(padding, Math.min(left, window.innerWidth - tooltip.width - padding))
|
||||||
|
top = Math.max(padding, Math.min(top, window.innerHeight - tooltip.height - padding))
|
||||||
|
setState((s) => ({ ...s, coords: { top, left } }))
|
||||||
|
}, [state.open, state.text, state.placement])
|
||||||
|
|
||||||
|
if (!state.open) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: state.coords?.top ?? 0,
|
||||||
|
left: state.coords?.left ?? 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
opacity: state.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 border text-xs shadow-lg whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{state.text}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { api } from '../api/client'
|
import { api } from '../../api/client'
|
||||||
|
|
||||||
export default function LoginDialog({ open, onClose }) {
|
export default function LoginDialog({ open, onClose }) {
|
||||||
const apiKey = useAuthStore((s) => s.apiKey)
|
const apiKey = useAuthStore((s) => s.apiKey)
|
||||||
53
frontend/src/components/common/Modal.jsx
Normal file
53
frontend/src/components/common/Modal.jsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 페이지에서 쓰는 일반 모달 래퍼
|
||||||
|
* - 열기/닫기 애니메이션 포함
|
||||||
|
* - 뒷배경 클릭으로는 닫히지 않음 (× 버튼만)
|
||||||
|
*/
|
||||||
|
export default function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
key="backdrop"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
||||||
|
style={{ background: 'var(--dialog-backdrop)' }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
key="dialog"
|
||||||
|
initial={{ opacity: 0, scale: 0.94, y: 8 }}
|
||||||
|
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 ${maxWidth} rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col`}
|
||||||
|
style={{
|
||||||
|
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
|
||||||
|
borderColor: 'var(--dialog-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-6 py-4 border-b flex items-center justify-between shrink-0"
|
||||||
|
style={{ borderColor: 'var(--panel-border)' }}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold" style={{ color: 'var(--text-strong)' }}>{title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center"
|
||||||
|
style={{ color: 'var(--text-dim)' }}
|
||||||
|
aria-label="닫기"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
frontend/src/components/common/Tooltip.jsx
Normal file
22
frontend/src/components/common/Tooltip.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* 기존 호환용 래퍼. 실제 툴팁 표시는 GlobalTooltip 이 담당.
|
||||||
|
*
|
||||||
|
* <Tooltip text="설명" placement="top" delay={200}>
|
||||||
|
* <button>+</button>
|
||||||
|
* </Tooltip>
|
||||||
|
*
|
||||||
|
* 새 코드는 그냥 `title="..."` 를 직접 써도 됨.
|
||||||
|
*/
|
||||||
|
export default function Tooltip({ text, children, placement = 'top', delay = 200 }) {
|
||||||
|
if (!text) return children
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={text}
|
||||||
|
data-tooltip-placement={placement}
|
||||||
|
data-tooltip-delay={delay}
|
||||||
|
className="inline-block"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { createContext, useContext, useState, useEffect } from 'react'
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
import { Outlet, Link, useLocation, useMatch } from 'react-router-dom'
|
import { Outlet, Link, useLocation, useMatch } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '../api/client'
|
import { api } from '../../api/client'
|
||||||
import Footer from './Footer'
|
import Footer from './Footer'
|
||||||
import LoginDialog from './LoginDialog'
|
import LoginDialog from '../common/LoginDialog'
|
||||||
import { useThemeStore } from '../stores/theme'
|
import { useThemeStore } from '../../stores/theme'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
const SITE_NAME = '메이플스토리 유틸리티'
|
const SITE_NAME = '메이플스토리 유틸리티'
|
||||||
|
|
||||||
165
frontend/src/components/pc/NoticeWidget/CarouselSection.jsx
Normal file
165
frontend/src/components/pc/NoticeWidget/CarouselSection.jsx
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { memo, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { fmtMD, fmtYMD, dayBadge } from './config'
|
||||||
|
|
||||||
|
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={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group relative block rounded-xl overflow-hidden border"
|
||||||
|
style={{
|
||||||
|
background: 'var(--panel-bg)',
|
||||||
|
borderColor: 'var(--panel-border)',
|
||||||
|
boxShadow: 'var(--panel-shadow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="aspect-[2/1] overflow-hidden"
|
||||||
|
style={{ background: 'var(--thumb-bg)' }}
|
||||||
|
>
|
||||||
|
{item.thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
src={item.thumbnail_url}
|
||||||
|
alt=""
|
||||||
|
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="text-xs tabular-nums" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
{dateText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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="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
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={clamped === 0}
|
||||||
|
className={navBtn}
|
||||||
|
aria-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>
|
||||||
|
<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="relative overflow-x-clip pb-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<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>
|
||||||
|
) : 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>
|
||||||
|
) : (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{slice.map((it) => <CardItem key={it.notice_id} item={it} cfg={cfg} />)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(CarouselSection)
|
||||||
140
frontend/src/components/pc/NoticeWidget/TextListSection.jsx
Normal file
140
frontend/src/components/pc/NoticeWidget/TextListSection.jsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { memo, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { fmtYMD, isRecent } from './config'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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] }}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(TextListSection)
|
||||||
62
frontend/src/components/pc/NoticeWidget/config.js
Normal file
62
frontend/src/components/pc/NoticeWidget/config.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
export 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtMD(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}
|
||||||
|
export 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')}`
|
||||||
|
}
|
||||||
|
export function isRecent(iso, days = 3) {
|
||||||
|
if (!iso) return false
|
||||||
|
return (Date.now() - new Date(iso).getTime()) / 86400000 < days
|
||||||
|
}
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
export 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
|
||||||
|
}
|
||||||
38
frontend/src/components/pc/NoticeWidget/index.jsx
Normal file
38
frontend/src/components/pc/NoticeWidget/index.jsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useQueries } from '@tanstack/react-query'
|
||||||
|
import { api } from '../../../api/client'
|
||||||
|
import { SECTIONS, isOngoing } from './config'
|
||||||
|
import TextListSection from './TextListSection'
|
||||||
|
import CarouselSection from './CarouselSection'
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
158
frontend/src/components/pc/SundayMapleBanner.jsx
Normal file
158
frontend/src/components/pc/SundayMapleBanner.jsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
||||||
|
import { api } from '../../api/client'
|
||||||
|
|
||||||
|
function SundayMapleDialog({ data, onClose }) {
|
||||||
|
// 배경 스크롤 잠금
|
||||||
|
useEffect(() => {
|
||||||
|
const prevBody = document.body.style.overflow
|
||||||
|
const prevHtml = document.documentElement.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
document.documentElement.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prevBody
|
||||||
|
document.documentElement.style.overflow = prevHtml
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const iconBtn = "w-8 h-8 rounded-lg backdrop-blur-sm border flex items-center justify-center hover:bg-[var(--row-hover-bg)]"
|
||||||
|
const iconBtnStyle = {
|
||||||
|
background: 'var(--btn-bg)',
|
||||||
|
borderColor: 'var(--btn-border)',
|
||||||
|
color: 'var(--text-emphasis)',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key="backdrop"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="fixed top-0 left-0 z-50 flex items-center justify-center p-4 backdrop-blur-md"
|
||||||
|
style={{
|
||||||
|
background: 'var(--dialog-backdrop)',
|
||||||
|
width: '100vw',
|
||||||
|
height: '100dvh',
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
key="dialog"
|
||||||
|
initial={{ opacity: 0, scale: 0.94, y: 8 }}
|
||||||
|
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="relative w-full max-w-[640px] max-h-[90vh] flex flex-col rounded-2xl border shadow-2xl overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: 'var(--panel-bg)',
|
||||||
|
borderColor: 'var(--panel-border)',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="absolute top-3 right-3 z-10 flex items-center gap-2">
|
||||||
|
{data.event_post_url && (
|
||||||
|
<a
|
||||||
|
href={data.event_post_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={iconBtn}
|
||||||
|
style={iconBtnStyle}
|
||||||
|
aria-label="공식 공지로 이동"
|
||||||
|
title="공식 공지로 이동"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M10 5H5a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2v-5M14 3h7m0 0v7m0-7L10 14"
|
||||||
|
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={`${iconBtn} text-xl leading-none`}
|
||||||
|
style={iconBtnStyle}
|
||||||
|
aria-label="닫기"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
className="flex-1 min-h-0"
|
||||||
|
style={{ overscrollBehavior: 'contain' }}
|
||||||
|
options={{
|
||||||
|
scrollbars: { theme: 'os-theme-maple os-theme-dark', autoHide: 'leave', autoHideDelay: 800 },
|
||||||
|
overflow: { x: 'hidden', y: 'scroll' },
|
||||||
|
}}
|
||||||
|
defer
|
||||||
|
>
|
||||||
|
<img src={data.image_url} alt="썬데이 메이플" className="w-full block" />
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SundayMapleBanner() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['sunday-maple', 'current'],
|
||||||
|
queryFn: () => api('/api/sunday-maple/current').catch(() => ({ available: false })),
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconName = data?.variant === 'special' ? '스페셜 썬데이 메이플' : '썬데이 메이플'
|
||||||
|
const { data: iconData } = useQuery({
|
||||||
|
queryKey: ['image', iconName],
|
||||||
|
queryFn: () => api(`/api/images/${encodeURIComponent(iconName)}`).catch(() => null),
|
||||||
|
enabled: !!data?.available,
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!data?.available) return null
|
||||||
|
|
||||||
|
const label = data.variant === 'special' ? '스페셜 썬데이 메이플' : '썬데이 메이플'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="w-full rounded-2xl border p-4 flex items-center gap-4 transition-transform duration-300 hover:scale-[1.01]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--selected-bg)',
|
||||||
|
borderColor: 'var(--selected-border)',
|
||||||
|
boxShadow: 'var(--panel-shadow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="shrink-0 w-14 h-14 rounded-xl flex items-center justify-center overflow-hidden"
|
||||||
|
style={{ background: 'var(--panel-bg)' }}
|
||||||
|
>
|
||||||
|
{iconData?.url ? (
|
||||||
|
<img src={iconData.url} alt={label} className="w-full h-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<span className="text-2xl">📅</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 text-left">
|
||||||
|
<div className="font-semibold text-base" style={{ color: 'var(--accent-bright)' }}>
|
||||||
|
이번 주 {label}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
일요일에 받을 수 있는 혜택을 확인하세요
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-sm font-medium" style={{ color: 'var(--accent-bright)' }}>
|
||||||
|
보기 →
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && <SundayMapleDialog data={data} onClose={() => setOpen(false)} />}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
0
frontend/src/components/tablet/.gitkeep
Normal file
0
frontend/src/components/tablet/.gitkeep
Normal file
|
|
@ -1,652 +0,0 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { api } from '../../api/client'
|
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
|
||||||
import { useAuthStore } from '../../stores/auth'
|
|
||||||
|
|
||||||
/* ── 공용 모달 ── */
|
|
||||||
function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
|
|
||||||
if (!open) return null
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
|
||||||
style={{ background: 'var(--dialog-backdrop)' }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`w-full ${maxWidth} rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col`}
|
|
||||||
style={{
|
|
||||||
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
|
|
||||||
borderColor: 'var(--dialog-border)',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="px-6 py-4 border-b flex items-center justify-between shrink-0"
|
|
||||||
style={{ borderColor: 'var(--panel-border)' }}
|
|
||||||
>
|
|
||||||
<h3 className="font-semibold" style={{ color: 'var(--text-strong)' }}>{title}</h3>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center"
|
|
||||||
style={{ color: 'var(--text-dim)' }}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 업로드 모달 (다중 지원) ── */
|
|
||||||
function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
|
|
||||||
const [items, setItems] = useState([])
|
|
||||||
const [dragOver, setDragOver] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) setItems([])
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const addFiles = (fileList) => {
|
|
||||||
const newItems = []
|
|
||||||
Array.from(fileList).forEach((file) => {
|
|
||||||
if (!file.type.startsWith('image/')) return
|
|
||||||
const id = `${Date.now()}-${Math.random()}`
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (e) => {
|
|
||||||
setItems((prev) => prev.map((it) => it.id === id ? { ...it, preview: e.target.result } : it))
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
newItems.push({
|
|
||||||
id,
|
|
||||||
file,
|
|
||||||
name: file.name.replace(/\.[^.]+$/, ''),
|
|
||||||
preview: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
setItems((prev) => [...prev, ...newItems])
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateName = (id, name) => {
|
|
||||||
setItems((prev) => prev.map((it) => it.id === id ? { ...it, name } : it))
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeItem = (id) => {
|
|
||||||
setItems((prev) => prev.filter((it) => it.id !== id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedNames = items.map((it) => it.name.trim())
|
|
||||||
const hasEmpty = trimmedNames.some((n) => !n)
|
|
||||||
const hasDupExisting = trimmedNames.some((n) => existingNames.has(n))
|
|
||||||
const hasDupInList = trimmedNames.some((n, i) => trimmedNames.indexOf(n) !== i)
|
|
||||||
const canSubmit = items.length > 0 && !hasEmpty && !hasDupExisting && !hasDupInList
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!canSubmit) return
|
|
||||||
await onUpload(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open={open} onClose={onClose} title={`이미지 업로드${items.length > 0 ? ` (${items.length})` : ''}`} maxWidth="max-w-2xl">
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
|
||||||
<div className="p-6 space-y-4 overflow-y-auto flex-1">
|
|
||||||
{/* 파일 추가 영역 */}
|
|
||||||
<label
|
|
||||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
|
||||||
onDragLeave={() => setDragOver(false)}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setDragOver(false)
|
|
||||||
addFiles(e.dataTransfer.files)
|
|
||||||
}}
|
|
||||||
className="relative rounded-xl border-2 border-dashed cursor-pointer min-h-[120px] flex flex-col items-center justify-center"
|
|
||||||
style={dragOver ? {
|
|
||||||
borderColor: 'var(--selected-border)',
|
|
||||||
background: 'var(--selected-bg)',
|
|
||||||
} : {
|
|
||||||
borderColor: 'var(--dashed-border)',
|
|
||||||
background: 'var(--skeleton-bg)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-2xl mb-1 opacity-50">📥</div>
|
|
||||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>클릭하거나 이미지를 끌어다 놓으세요</p>
|
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-dim)' }}>여러 개 선택 가능</p>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
onChange={(e) => { addFiles(e.target.files); e.target.value = '' }}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* 선택된 파일 리스트 */}
|
|
||||||
{items.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{items.map((item, idx) => {
|
|
||||||
const trimmed = item.name.trim()
|
|
||||||
const dupExisting = trimmed && existingNames.has(trimmed)
|
|
||||||
const dupInList = trimmed && items.some((it, j) => j !== idx && it.name.trim() === trimmed)
|
|
||||||
const empty = !trimmed
|
|
||||||
const errorMsg = empty ? '이름을 입력해주세요'
|
|
||||||
: dupExisting ? '이미 존재하는 이름입니다'
|
|
||||||
: dupInList ? '같은 이름이 중복됩니다'
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="flex items-start gap-3 rounded-lg border p-2"
|
|
||||||
style={{
|
|
||||||
background: 'var(--surface-3)',
|
|
||||||
borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--panel-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-12 h-12 rounded flex items-center justify-center overflow-hidden shrink-0"
|
|
||||||
style={{ background: 'var(--surface-nested)' }}
|
|
||||||
>
|
|
||||||
{item.preview ? (
|
|
||||||
<img src={item.preview} alt="" className="w-full h-full object-contain" />
|
|
||||||
) : (
|
|
||||||
<div className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 space-y-0.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={item.name}
|
|
||||||
onChange={(e) => updateName(item.id, e.target.value)}
|
|
||||||
className="w-full rounded border px-2 py-1.5 text-sm outline-none"
|
|
||||||
style={{
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--input-border)',
|
|
||||||
color: 'var(--text-strong)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{errorMsg && (
|
|
||||||
<div className="text-[11px] px-0.5" style={{ color: 'var(--danger-text)' }}>{errorMsg}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeItem(item.id)}
|
|
||||||
className="w-7 h-7 rounded shrink-0 hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)]"
|
|
||||||
style={{ color: 'var(--text-dim)' }}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 버튼 */}
|
|
||||||
<div
|
|
||||||
className="flex gap-2 px-6 py-4 border-t shrink-0"
|
|
||||||
style={{ borderColor: 'var(--panel-border)' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 rounded-lg border px-4 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!canSubmit || uploading}
|
|
||||||
className="flex-1 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--btn-primary-bg-hover)]"
|
|
||||||
style={{
|
|
||||||
background: 'var(--btn-primary-bg)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
boxShadow: 'var(--btn-primary-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{uploading ? '업로드 중...' : `${items.length > 0 ? `${items.length}개 ` : ''}업로드`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 이미지 카드 ── */
|
|
||||||
function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={() => selectMode && onToggle(image.id)}
|
|
||||||
className={`group relative rounded-xl border overflow-hidden ${selectMode ? 'cursor-pointer' : ''}`}
|
|
||||||
style={{
|
|
||||||
borderColor: selected ? 'var(--selected-border)' : 'var(--panel-border)',
|
|
||||||
background: selected ? 'var(--selected-bg)' : 'var(--panel-bg)',
|
|
||||||
boxShadow: selected ? '0 0 0 2px var(--ring-info)' : 'var(--panel-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectMode && (
|
|
||||||
<div
|
|
||||||
className="absolute top-2 left-2 z-10 w-5 h-5 rounded border-2 flex items-center justify-center"
|
|
||||||
style={selected ? {
|
|
||||||
borderColor: 'var(--accent)',
|
|
||||||
background: 'var(--accent)',
|
|
||||||
} : {
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
background: 'var(--surface-3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selected && <span className="text-xs" style={{ color: 'var(--btn-primary-text)' }}>✓</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="aspect-square flex items-center justify-center p-4 relative"
|
|
||||||
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={image.url}
|
|
||||||
alt={image.name}
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
style={{ imageRendering: 'pixelated' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!selectMode && (
|
|
||||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onCopyUrl(image) }}
|
|
||||||
className="w-7 h-7 rounded-md backdrop-blur-sm border text-xs flex items-center justify-center hover:bg-[var(--selected-bg)] hover:border-[var(--selected-border)]"
|
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
title="URL 복사"
|
|
||||||
>
|
|
||||||
{copied ? '✓' : '⧉'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="px-3 py-2 border-t"
|
|
||||||
style={{ borderColor: 'var(--panel-border)' }}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium truncate">{image.name}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 페이지네이션 ── */
|
|
||||||
function Pagination({ page, totalPages, onChange }) {
|
|
||||||
if (totalPages <= 1) return null
|
|
||||||
|
|
||||||
const pages = []
|
|
||||||
const maxButtons = 7
|
|
||||||
let start = Math.max(1, page - Math.floor(maxButtons / 2))
|
|
||||||
let end = Math.min(totalPages, start + maxButtons - 1)
|
|
||||||
if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1)
|
|
||||||
for (let i = start; i <= end; i++) pages.push(i)
|
|
||||||
|
|
||||||
const baseBtn = "min-w-9 h-9 px-3 rounded-lg text-sm flex items-center justify-center border hover:bg-[var(--btn-bg-hover)]"
|
|
||||||
const btnStyle = {
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center gap-1 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(page - 1)}
|
|
||||||
disabled={page === 1}
|
|
||||||
className={`${baseBtn} disabled:opacity-30 disabled:cursor-not-allowed`}
|
|
||||||
style={btnStyle}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{start > 1 && (
|
|
||||||
<>
|
|
||||||
<button onClick={() => onChange(1)} className={baseBtn} style={btnStyle}>1</button>
|
|
||||||
{start > 2 && <span className="px-1" style={{ color: 'var(--text-dim)' }}>…</span>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pages.map((p) => {
|
|
||||||
const active = p === page
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={p}
|
|
||||||
onClick={() => onChange(p)}
|
|
||||||
className={`${baseBtn} ${active ? 'font-medium' : ''}`}
|
|
||||||
style={active ? {
|
|
||||||
background: 'var(--selected-bg)',
|
|
||||||
borderColor: 'var(--selected-border)',
|
|
||||||
color: 'var(--accent-bright)',
|
|
||||||
} : btnStyle}
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{end < totalPages && (
|
|
||||||
<>
|
|
||||||
{end < totalPages - 1 && <span className="px-1" style={{ color: 'var(--text-dim)' }}>…</span>}
|
|
||||||
<button onClick={() => onChange(totalPages)} className={baseBtn} style={btnStyle}>{totalPages}</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(page + 1)}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
className={`${baseBtn} disabled:opacity-30 disabled:cursor-not-allowed`}
|
|
||||||
style={btnStyle}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_SIZE = 24
|
|
||||||
|
|
||||||
/* ── 메인 ── */
|
|
||||||
export default function AdminImages() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
||||||
const [uploadOpen, setUploadOpen] = useState(false)
|
|
||||||
const [selectMode, setSelectMode] = useState(false)
|
|
||||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null)
|
|
||||||
const [copiedId, setCopiedId] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
setDebouncedSearch(search)
|
|
||||||
setPage(1)
|
|
||||||
}, 300)
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
}, [search])
|
|
||||||
|
|
||||||
const { data: imagesData, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page,
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
...(debouncedSearch && { search: debouncedSearch }),
|
|
||||||
})
|
|
||||||
return api(`/api/admin/images?${params}`)
|
|
||||||
},
|
|
||||||
placeholderData: (prev) => prev,
|
|
||||||
})
|
|
||||||
|
|
||||||
const images = imagesData?.items || []
|
|
||||||
const totalPages = imagesData?.total_pages || 1
|
|
||||||
|
|
||||||
const { data: allNamesArray = [] } = useQuery({
|
|
||||||
queryKey: ['admin', 'images', 'names'],
|
|
||||||
queryFn: () => api('/api/admin/images/names'),
|
|
||||||
})
|
|
||||||
const allNames = new Set(allNamesArray)
|
|
||||||
|
|
||||||
const invalidateImages = () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'images'] })
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
|
||||||
mutationFn: async (items) => {
|
|
||||||
const formData = new FormData()
|
|
||||||
items.forEach((it) => {
|
|
||||||
formData.append('files', it.file)
|
|
||||||
formData.append('names', it.name.trim())
|
|
||||||
})
|
|
||||||
const adminKey = useAuthStore.getState().apiKey
|
|
||||||
const res = await fetch('/api/admin/images', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'x-admin-key': adminKey },
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
const result = await res.json()
|
|
||||||
if (!res.ok) throw new Error(result.error || '업로드 실패')
|
|
||||||
return result
|
|
||||||
},
|
|
||||||
onSuccess: (result) => {
|
|
||||||
if (result.errors?.length > 0) {
|
|
||||||
alert(`일부 업로드 실패:\n${result.errors.map((e) => `- ${e.name}: ${e.error}`).join('\n')}`)
|
|
||||||
}
|
|
||||||
setUploadOpen(false)
|
|
||||||
invalidateImages()
|
|
||||||
},
|
|
||||||
onError: (err) => alert(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleSelect = (id) => {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.has(id) ? next.delete(id) : next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSelectMode = () => {
|
|
||||||
setSelectMode((prev) => !prev)
|
|
||||||
setSelectedIds(new Set())
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
if (selectedIds.size === images.length) {
|
|
||||||
setSelectedIds(new Set())
|
|
||||||
} else {
|
|
||||||
setSelectedIds(new Set(images.map((img) => img.id)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestDelete = () => {
|
|
||||||
const items = images.filter((img) => selectedIds.has(img.id))
|
|
||||||
setConfirmDelete({
|
|
||||||
ids: items.map((i) => i.id),
|
|
||||||
names: items.map((i) => i.name),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (ids) => api('/api/admin/images/delete', { method: 'POST', body: { ids } }),
|
|
||||||
onSuccess: () => {
|
|
||||||
setConfirmDelete(null)
|
|
||||||
setSelectedIds(new Set())
|
|
||||||
setSelectMode(false)
|
|
||||||
invalidateImages()
|
|
||||||
},
|
|
||||||
onError: (err) => alert(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const copyUrl = (image) => {
|
|
||||||
navigator.clipboard.writeText(image.url)
|
|
||||||
setCopiedId(image.id)
|
|
||||||
setTimeout(() => setCopiedId(null), 1500)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-5xl mx-auto pt-6">
|
|
||||||
<div className="flex items-end justify-between gap-4 flex-wrap">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-medium">이미지 관리</h2>
|
|
||||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>공용 이미지를 업로드하고 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{selectMode ? (
|
|
||||||
<>
|
|
||||||
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>{selectedIds.size}개 선택</span>
|
|
||||||
<button
|
|
||||||
onClick={selectAll}
|
|
||||||
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedIds.size === images.length && images.length > 0 ? '전체 해제' : '전체 선택'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={requestDelete}
|
|
||||||
disabled={selectedIds.size === 0}
|
|
||||||
className="rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--btn-danger-bg-hover)]"
|
|
||||||
style={{
|
|
||||||
background: 'var(--btn-danger-bg)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
boxShadow: 'var(--btn-danger-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={toggleSelectMode}
|
|
||||||
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
완료
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{images.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={toggleSelectMode}
|
|
||||||
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--danger-bg-hover)]"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--icon-danger-border)',
|
|
||||||
color: 'var(--danger-text)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setUploadOpen(true)}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium hover:bg-[var(--btn-primary-bg-hover)]"
|
|
||||||
style={{
|
|
||||||
background: 'var(--btn-primary-bg)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
boxShadow: 'var(--btn-primary-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-base leading-none">+</span>
|
|
||||||
이미지 업로드
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 검색 */}
|
|
||||||
{images.length > 0 && (
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="이미지 이름으로 검색..."
|
|
||||||
className="w-full rounded-lg border pl-10 pr-4 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)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--input-icon)' }}>🔍</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 이미지 그리드 */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="aspect-square rounded-xl animate-pulse"
|
|
||||||
style={{ background: 'var(--skeleton-bg)' }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : images.length === 0 ? (
|
|
||||||
<div
|
|
||||||
className="rounded-2xl border border-dashed p-16 text-center"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--dashed-border)',
|
|
||||||
background: 'var(--skeleton-bg)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-5xl mb-3 opacity-30">🖼️</div>
|
|
||||||
<p className="mb-4" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
|
|
||||||
</p>
|
|
||||||
{!debouncedSearch && (
|
|
||||||
<button
|
|
||||||
onClick={() => setUploadOpen(true)}
|
|
||||||
className="text-sm hover:text-[var(--accent-hover-text)]"
|
|
||||||
style={{ color: 'var(--accent)' }}
|
|
||||||
>
|
|
||||||
첫 이미지 업로드하기 →
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
|
||||||
{images.map((image) => (
|
|
||||||
<ImageCard
|
|
||||||
key={image.id}
|
|
||||||
image={image}
|
|
||||||
selected={selectedIds.has(image.id)}
|
|
||||||
selectMode={selectMode}
|
|
||||||
onToggle={toggleSelect}
|
|
||||||
onCopyUrl={copyUrl}
|
|
||||||
copied={copiedId === image.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<UploadModal
|
|
||||||
open={uploadOpen}
|
|
||||||
onClose={() => setUploadOpen(false)}
|
|
||||||
onUpload={(items) => uploadMutation.mutate(items)}
|
|
||||||
uploading={uploadMutation.isPending}
|
|
||||||
existingNames={allNames}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={!!confirmDelete}
|
|
||||||
onClose={() => setConfirmDelete(null)}
|
|
||||||
onConfirm={() => deleteMutation.mutate(confirmDelete.ids)}
|
|
||||||
title="이미지 삭제"
|
|
||||||
description={confirmDelete ? `${confirmDelete.ids.length}개의 이미지를 삭제하시겠습니까?\n\n${confirmDelete.names.slice(0, 5).map((n) => `· ${n}`).join('\n')}${confirmDelete.names.length > 5 ? `\n· 외 ${confirmDelete.names.length - 5}개` : ''}\n\n이 작업은 되돌릴 수 없습니다.` : ''}
|
|
||||||
confirmText="삭제"
|
|
||||||
destructive
|
|
||||||
loading={deleteMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { api } from '../../../api/client'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 24
|
|
||||||
|
|
||||||
export default function ImagePicker({ open, onClose, onSelect, currentImageId }) {
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
setDebouncedSearch(search)
|
|
||||||
setPage(1)
|
|
||||||
}, 300)
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
}, [search])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setSearch('')
|
|
||||||
setDebouncedSearch('')
|
|
||||||
setPage(1)
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
|
|
||||||
queryFn: () => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page,
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
...(debouncedSearch && { search: debouncedSearch }),
|
|
||||||
})
|
|
||||||
return api(`/api/admin/images?${params}`)
|
|
||||||
},
|
|
||||||
enabled: open,
|
|
||||||
placeholderData: (prev) => prev,
|
|
||||||
})
|
|
||||||
|
|
||||||
const images = data?.items || []
|
|
||||||
const totalPages = data?.total_pages || 1
|
|
||||||
|
|
||||||
if (!open) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
|
||||||
style={{ background: 'var(--dialog-backdrop)' }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-3xl rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col"
|
|
||||||
style={{
|
|
||||||
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
|
|
||||||
borderColor: 'var(--dialog-border)',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="px-6 py-4 border-b flex items-center justify-between shrink-0"
|
|
||||||
style={{ borderColor: 'var(--panel-border)' }}
|
|
||||||
>
|
|
||||||
<h3 className="font-semibold" style={{ color: 'var(--text-strong)' }}>이미지 선택</h3>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center"
|
|
||||||
style={{ color: 'var(--text-dim)' }}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 검색 */}
|
|
||||||
<div className="px-6 pt-4 shrink-0">
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="이미지 이름으로 검색..."
|
|
||||||
className="w-full rounded-lg border pl-10 pr-4 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)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--input-icon)' }}>🔍</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 이미지 그리드 */}
|
|
||||||
<div className="px-6 py-4 overflow-y-auto flex-1">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="aspect-square rounded-lg animate-pulse"
|
|
||||||
style={{ background: 'var(--skeleton-bg)' }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : images.length === 0 ? (
|
|
||||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-dim)' }}>
|
|
||||||
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
|
||||||
{images.map((image) => {
|
|
||||||
const isSelected = currentImageId === image.id
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={image.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => { onSelect(image); onClose() }}
|
|
||||||
className="group rounded-lg border overflow-hidden"
|
|
||||||
style={{
|
|
||||||
borderColor: isSelected ? 'var(--selected-border)' : 'var(--panel-border)',
|
|
||||||
boxShadow: isSelected ? '0 0 0 2px var(--ring-info)' : undefined,
|
|
||||||
}}
|
|
||||||
title={image.name}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="aspect-square flex items-center justify-center p-3"
|
|
||||||
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
|
|
||||||
>
|
|
||||||
<img src={image.url} alt={image.name} className="max-w-full max-h-full object-contain" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="px-2 py-1.5 border-t"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
background: 'var(--surface-3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-xs truncate">{image.name}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 페이지네이션 + 액션 */}
|
|
||||||
<div
|
|
||||||
className="px-6 py-4 border-t flex items-center justify-between shrink-0 gap-3"
|
|
||||||
style={{ borderColor: 'var(--panel-border)' }}
|
|
||||||
>
|
|
||||||
{totalPages > 1 ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
|
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<span className="text-xs px-2" style={{ color: 'var(--text-muted)' }}>{page} / {totalPages}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
|
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : <div />}
|
|
||||||
|
|
||||||
{currentImageId && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { onSelect(null); onClose() }}
|
|
||||||
className="text-sm hover:text-[var(--danger-text-strong)]"
|
|
||||||
style={{ color: 'var(--danger-text)' }}
|
|
||||||
>
|
|
||||||
이미지 제거
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getAdminComponent } from '../registry'
|
import { getAdminComponent } from '../../registry'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../../api/client'
|
||||||
|
|
||||||
export default function AdminFeaturePage() {
|
export default function AdminFeaturePage() {
|
||||||
const { slug } = useParams()
|
const { slug } = useParams()
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../../api/client'
|
||||||
|
|
||||||
function MenuCard({ menu }) {
|
function MenuCard({ menu }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
298
frontend/src/features/admin/pc/AdminImages.jsx
Normal file
298
frontend/src/features/admin/pc/AdminImages.jsx
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { api } from '../../../api/client'
|
||||||
|
import ConfirmDialog from '../../../components/common/ConfirmDialog'
|
||||||
|
import { useAuthStore } from '../../../stores/auth'
|
||||||
|
import ImageCard from './components/ImageCard'
|
||||||
|
import Pagination from './components/Pagination'
|
||||||
|
import UploadModal from './components/UploadModal'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24
|
||||||
|
|
||||||
|
export default function AdminImages() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false)
|
||||||
|
const [selectMode, setSelectMode] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(null)
|
||||||
|
const [copiedId, setCopiedId] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setDebouncedSearch(search)
|
||||||
|
setPage(1)
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
const { data: imagesData, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
...(debouncedSearch && { search: debouncedSearch }),
|
||||||
|
})
|
||||||
|
return api(`/api/admin/images?${params}`)
|
||||||
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
})
|
||||||
|
|
||||||
|
const images = imagesData?.items || []
|
||||||
|
const totalPages = imagesData?.total_pages || 1
|
||||||
|
|
||||||
|
const { data: allNamesArray = [] } = useQuery({
|
||||||
|
queryKey: ['admin', 'images', 'names'],
|
||||||
|
queryFn: () => api('/api/admin/images/names'),
|
||||||
|
})
|
||||||
|
const allNames = new Set(allNamesArray)
|
||||||
|
|
||||||
|
const invalidateImages = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'images'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: async (items) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
items.forEach((it) => {
|
||||||
|
formData.append('files', it.file)
|
||||||
|
formData.append('names', it.name.trim())
|
||||||
|
})
|
||||||
|
const adminKey = useAuthStore.getState().apiKey
|
||||||
|
const res = await fetch('/api/admin/images', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-admin-key': adminKey },
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
const result = await res.json()
|
||||||
|
if (!res.ok) throw new Error(result.error || '업로드 실패')
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.errors?.length > 0) {
|
||||||
|
alert(`일부 업로드 실패:\n${result.errors.map((e) => `- ${e.name}: ${e.error}`).join('\n')}`)
|
||||||
|
}
|
||||||
|
setUploadOpen(false)
|
||||||
|
invalidateImages()
|
||||||
|
},
|
||||||
|
onError: (err) => alert(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleSelect = (id) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelectMode = () => {
|
||||||
|
setSelectMode((prev) => !prev)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
if (selectedIds.size === images.length) {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set(images.map((img) => img.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestDelete = () => {
|
||||||
|
const items = images.filter((img) => selectedIds.has(img.id))
|
||||||
|
setConfirmDelete({
|
||||||
|
ids: items.map((i) => i.id),
|
||||||
|
names: items.map((i) => i.name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (ids) => api('/api/admin/images/delete', { method: 'POST', body: { ids } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setConfirmDelete(null)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setSelectMode(false)
|
||||||
|
invalidateImages()
|
||||||
|
},
|
||||||
|
onError: (err) => alert(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyUrl = (image) => {
|
||||||
|
navigator.clipboard.writeText(image.url)
|
||||||
|
setCopiedId(image.id)
|
||||||
|
setTimeout(() => setCopiedId(null), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-5xl mx-auto pt-6">
|
||||||
|
<div className="flex items-end justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-medium">이미지 관리</h2>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>공용 이미지를 업로드하고 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectMode ? (
|
||||||
|
<>
|
||||||
|
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>{selectedIds.size}개 선택</span>
|
||||||
|
<button
|
||||||
|
onClick={selectAll}
|
||||||
|
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--btn-bg)',
|
||||||
|
borderColor: 'var(--btn-border)',
|
||||||
|
color: 'var(--text-emphasis)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedIds.size === images.length && images.length > 0 ? '전체 해제' : '전체 선택'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={requestDelete}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50 hover:bg-[var(--btn-danger-bg-hover)]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--btn-danger-bg)',
|
||||||
|
color: 'var(--btn-primary-text)',
|
||||||
|
boxShadow: 'var(--btn-danger-shadow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleSelectMode}
|
||||||
|
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--btn-bg)',
|
||||||
|
borderColor: 'var(--btn-border)',
|
||||||
|
color: 'var(--text-emphasis)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
완료
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{images.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={toggleSelectMode}
|
||||||
|
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--danger-bg-hover)]"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--icon-danger-border)',
|
||||||
|
color: 'var(--danger-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setUploadOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium hover:bg-[var(--btn-primary-bg-hover)]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--btn-primary-bg)',
|
||||||
|
color: 'var(--btn-primary-text)',
|
||||||
|
boxShadow: 'var(--btn-primary-shadow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">+</span>
|
||||||
|
이미지 업로드
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="이미지 이름으로 검색..."
|
||||||
|
className="w-full rounded-lg border pl-10 pr-4 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)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--input-icon)' }}>🔍</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 이미지 그리드 */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="aspect-square rounded-xl animate-pulse"
|
||||||
|
style={{ background: 'var(--skeleton-bg)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : images.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border border-dashed p-16 text-center"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--dashed-border)',
|
||||||
|
background: 'var(--skeleton-bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-5xl mb-3 opacity-30">🖼️</div>
|
||||||
|
<p className="mb-4" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
|
||||||
|
</p>
|
||||||
|
{!debouncedSearch && (
|
||||||
|
<button
|
||||||
|
onClick={() => setUploadOpen(true)}
|
||||||
|
className="text-sm hover:text-[var(--accent-hover-text)]"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
첫 이미지 업로드하기 →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
|
{images.map((image) => (
|
||||||
|
<ImageCard
|
||||||
|
key={image.id}
|
||||||
|
image={image}
|
||||||
|
selected={selectedIds.has(image.id)}
|
||||||
|
selectMode={selectMode}
|
||||||
|
onToggle={toggleSelect}
|
||||||
|
onCopyUrl={copyUrl}
|
||||||
|
copied={copiedId === image.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<UploadModal
|
||||||
|
open={uploadOpen}
|
||||||
|
onClose={() => setUploadOpen(false)}
|
||||||
|
onUpload={(items) => uploadMutation.mutate(items)}
|
||||||
|
uploading={uploadMutation.isPending}
|
||||||
|
existingNames={allNames}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDelete}
|
||||||
|
onClose={() => setConfirmDelete(null)}
|
||||||
|
onConfirm={() => deleteMutation.mutate(confirmDelete.ids)}
|
||||||
|
title="이미지 삭제"
|
||||||
|
description={confirmDelete ? `${confirmDelete.ids.length}개의 이미지를 삭제하시겠습니까?\n\n${confirmDelete.names.slice(0, 5).map((n) => `· ${n}`).join('\n')}${confirmDelete.names.length > 5 ? `\n· 외 ${confirmDelete.names.length - 5}개` : ''}\n\n이 작업은 되돌릴 수 없습니다.` : ''}
|
||||||
|
confirmText="삭제"
|
||||||
|
destructive
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Outlet, Navigate } from 'react-router-dom'
|
import { Outlet, Navigate } from 'react-router-dom'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../../api/client'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../../stores/auth'
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -1,31 +1,10 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../../api/client'
|
||||||
import ImagePicker from './components/ImagePicker'
|
import ImagePicker from './components/ImagePicker'
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
import ConfirmDialog from '../../../components/common/ConfirmDialog'
|
||||||
|
import FormField, { formInputClass, formInputStyle } from '../../../components/common/FormField'
|
||||||
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" style={{ color: 'var(--text-emphasis)' }}>
|
|
||||||
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
|
|
||||||
</label>
|
|
||||||
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
|
|
||||||
const inputStyle = {
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
color: 'var(--text-strong)',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminMenuForm() {
|
export default function AdminMenuForm() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -175,29 +154,29 @@ export default function AdminMenuForm() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="제목" required error={errors.title}>
|
<FormField label="제목" required error={errors.title}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={(e) => update({ title: e.target.value })}
|
onChange={(e) => update({ title: e.target.value })}
|
||||||
placeholder="예: 주간 보스 수익 계산기"
|
placeholder="예: 주간 보스 수익 계산기"
|
||||||
className={inputCls}
|
className={formInputClass}
|
||||||
style={inputStyle}
|
style={formInputStyle}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="설명" hint="카드에 표시되는 부가 설명">
|
<FormField label="설명" hint="카드에 표시되는 부가 설명">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(e) => update({ description: e.target.value })}
|
onChange={(e) => update({ description: e.target.value })}
|
||||||
placeholder="예: 캐릭터별 보스 결정석 수익을 계산합니다"
|
placeholder="예: 캐릭터별 보스 결정석 수익을 계산합니다"
|
||||||
className={inputCls}
|
className={formInputClass}
|
||||||
style={inputStyle}
|
style={formInputStyle}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="경로" required error={errors.slug}>
|
<FormField label="경로" required error={errors.slug}>
|
||||||
<div
|
<div
|
||||||
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -234,9 +213,9 @@ export default function AdminMenuForm() {
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="아이콘 이미지" hint="선택사항">
|
<FormField label="아이콘 이미지" hint="선택사항">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -271,7 +250,7 @@ export default function AdminMenuForm() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
68
frontend/src/features/admin/pc/components/ImageCard.jsx
Normal file
68
frontend/src/features/admin/pc/components/ImageCard.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => selectMode && onToggle(image.id)}
|
||||||
|
className={`group relative rounded-xl border overflow-hidden ${selectMode ? 'cursor-pointer' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderColor: selected ? 'var(--selected-border)' : 'var(--panel-border)',
|
||||||
|
background: selected ? 'var(--selected-bg)' : 'var(--panel-bg)',
|
||||||
|
boxShadow: selected ? '0 0 0 2px var(--ring-info)' : 'var(--panel-shadow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectMode && (
|
||||||
|
<div
|
||||||
|
className="absolute top-2 left-2 z-10 w-5 h-5 rounded border-2 flex items-center justify-center"
|
||||||
|
style={selected ? {
|
||||||
|
borderColor: 'var(--accent)',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
} : {
|
||||||
|
borderColor: 'var(--panel-border)',
|
||||||
|
background: 'var(--surface-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected && <span className="text-xs" style={{ color: 'var(--btn-primary-text)' }}>✓</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="aspect-square flex items-center justify-center p-4 relative"
|
||||||
|
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={image.name}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
style={{ imageRendering: 'pixelated' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!selectMode && (
|
||||||
|
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onCopyUrl(image) }}
|
||||||
|
className="w-7 h-7 rounded-md backdrop-blur-sm border text-xs flex items-center justify-center hover:bg-[var(--selected-bg)] hover:border-[var(--selected-border)]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--btn-bg)',
|
||||||
|
borderColor: 'var(--btn-border)',
|
||||||
|
color: 'var(--text-emphasis)',
|
||||||
|
}}
|
||||||
|
title="URL 복사"
|
||||||
|
>
|
||||||
|
{copied ? '✓' : '⧉'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 border-t"
|
||||||
|
style={{ borderColor: 'var(--panel-border)' }}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium truncate">{image.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ImageCard)
|
||||||
217
frontend/src/features/admin/pc/components/ImagePicker.jsx
Normal file
217
frontend/src/features/admin/pc/components/ImagePicker.jsx
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
||||||
|
import { api } from '../../../../api/client'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24
|
||||||
|
|
||||||
|
export default function ImagePicker({ open, onClose, onSelect, currentImageId }) {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setDebouncedSearch(search)
|
||||||
|
setPage(1)
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSearch('')
|
||||||
|
setDebouncedSearch('')
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
...(debouncedSearch && { search: debouncedSearch }),
|
||||||
|
})
|
||||||
|
return api(`/api/admin/images?${params}`)
|
||||||
|
},
|
||||||
|
enabled: open,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
})
|
||||||
|
|
||||||
|
const images = data?.items || []
|
||||||
|
const totalPages = data?.total_pages || 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
key="backdrop"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
||||||
|
style={{ background: 'var(--dialog-backdrop)' }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
key="dialog"
|
||||||
|
initial={{ opacity: 0, scale: 0.94, y: 8 }}
|
||||||
|
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-3xl rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col"
|
||||||
|
style={{
|
||||||
|
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
|
||||||
|
borderColor: 'var(--dialog-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-6 py-4 border-b flex items-center justify-between shrink-0"
|
||||||
|
style={{ borderColor: 'var(--panel-border)' }}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold" style={{ color: 'var(--text-strong)' }}>이미지 선택</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center"
|
||||||
|
style={{ color: 'var(--text-dim)' }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="px-6 pt-4 shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="이미지 이름으로 검색..."
|
||||||
|
className="w-full rounded-lg border pl-10 pr-4 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)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--input-icon)' }}>🔍</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이미지 그리드 — 6×4 (24개) 높이로 고정, 내부는 OverlayScrollbars */}
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ height: '632px', overscrollBehavior: 'contain' }}
|
||||||
|
options={{
|
||||||
|
scrollbars: { theme: 'os-theme-maple os-theme-dark', autoHide: 'leave', autoHideDelay: 800 },
|
||||||
|
overflow: { x: 'hidden', y: 'scroll' },
|
||||||
|
}}
|
||||||
|
defer
|
||||||
|
>
|
||||||
|
<div className="px-6 pt-4 pb-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="aspect-square rounded-lg animate-pulse"
|
||||||
|
style={{ background: 'var(--skeleton-bg)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : images.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
|
{images.map((image) => {
|
||||||
|
const isSelected = currentImageId === image.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={image.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onSelect(image); onClose() }}
|
||||||
|
className="group rounded-lg border overflow-hidden"
|
||||||
|
style={{
|
||||||
|
borderColor: isSelected ? 'var(--selected-border)' : 'var(--panel-border)',
|
||||||
|
boxShadow: isSelected ? '0 0 0 2px var(--ring-info)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="aspect-square flex items-center justify-center p-4"
|
||||||
|
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
|
||||||
|
>
|
||||||
|
<img src={image.url} alt={image.name} className="w-full h-full object-contain" style={{ imageRendering: 'pixelated' }} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="px-2 py-1.5 border-t"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--panel-border)',
|
||||||
|
background: 'var(--surface-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-xs truncate">{image.name}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
|
||||||
|
{/* 페이지네이션 + 액션 (없으면 전체 섹션 숨김) */}
|
||||||
|
{(totalPages > 1 || currentImageId) && (
|
||||||
|
<div className="px-6 pb-6 pt-1 flex items-center justify-between shrink-0 gap-3">
|
||||||
|
{totalPages > 1 ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 flex items-center justify-center text-sm"
|
||||||
|
style={{
|
||||||
|
background: 'var(--btn-bg)',
|
||||||
|
borderColor: 'var(--btn-border)',
|
||||||
|
color: 'var(--text-emphasis)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span className="text-xs px-2" style={{ color: 'var(--text-muted)' }}>{page} / {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 flex items-center justify-center text-sm"
|
||||||
|
style={{
|
||||||
|
background: 'var(--btn-bg)',
|
||||||
|
borderColor: 'var(--btn-border)',
|
||||||
|
color: 'var(--text-emphasis)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : <div />}
|
||||||
|
|
||||||
|
{currentImageId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onSelect(null); onClose() }}
|
||||||
|
className="text-sm hover:text-[var(--danger-text-strong)]"
|
||||||
|
style={{ color: 'var(--danger-text)' }}
|
||||||
|
>
|
||||||
|
이미지 제거
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
frontend/src/features/admin/pc/components/Pagination.jsx
Normal file
71
frontend/src/features/admin/pc/components/Pagination.jsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
export default function Pagination({ page, totalPages, onChange }) {
|
||||||
|
if (totalPages <= 1) return null
|
||||||
|
|
||||||
|
const pages = []
|
||||||
|
const maxButtons = 7
|
||||||
|
let start = Math.max(1, page - Math.floor(maxButtons / 2))
|
||||||
|
let end = Math.min(totalPages, start + maxButtons - 1)
|
||||||
|
if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1)
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i)
|
||||||
|
|
||||||
|
const baseBtn = "min-w-9 h-9 px-3 rounded-lg text-sm flex items-center justify-center border hover:bg-[var(--btn-bg-hover)]"
|
||||||
|
const btnStyle = {
|
||||||
|
background: 'var(--btn-bg)',
|
||||||
|
borderColor: 'var(--btn-border)',
|
||||||
|
color: 'var(--text-emphasis)',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-1 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
className={`${baseBtn} disabled:opacity-30`}
|
||||||
|
style={btnStyle}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{start > 1 && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => onChange(1)} className={baseBtn} style={btnStyle}>1</button>
|
||||||
|
{start > 2 && <span className="px-1" style={{ color: 'var(--text-dim)' }}>…</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pages.map((p) => {
|
||||||
|
const active = p === page
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onChange(p)}
|
||||||
|
className={`${baseBtn} ${active ? 'font-medium' : ''}`}
|
||||||
|
style={active ? {
|
||||||
|
background: 'var(--selected-bg)',
|
||||||
|
borderColor: 'var(--selected-border)',
|
||||||
|
color: 'var(--accent-bright)',
|
||||||
|
} : btnStyle}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{end < totalPages && (
|
||||||
|
<>
|
||||||
|
{end < totalPages - 1 && <span className="px-1" style={{ color: 'var(--text-dim)' }}>…</span>}
|
||||||
|
<button onClick={() => onChange(totalPages)} className={baseBtn} style={btnStyle}>{totalPages}</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(page + 1)}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className={`${baseBtn} disabled:opacity-30`}
|
||||||
|
style={btnStyle}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
179
frontend/src/features/admin/pc/components/UploadModal.jsx
Normal file
179
frontend/src/features/admin/pc/components/UploadModal.jsx
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Modal from '../../../../components/common/Modal'
|
||||||
|
|
||||||
|
export default function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setItems([])
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const addFiles = (fileList) => {
|
||||||
|
const newItems = []
|
||||||
|
Array.from(fileList).forEach((file) => {
|
||||||
|
if (!file.type.startsWith('image/')) return
|
||||||
|
const id = `${Date.now()}-${Math.random()}`
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setItems((prev) => prev.map((it) => it.id === id ? { ...it, preview: e.target.result } : it))
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
newItems.push({
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
name: file.name.replace(/\.[^.]+$/, ''),
|
||||||
|
preview: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
setItems((prev) => [...prev, ...newItems])
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateName = (id, name) => {
|
||||||
|
setItems((prev) => prev.map((it) => it.id === id ? { ...it, name } : it))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeItem = (id) => {
|
||||||
|
setItems((prev) => prev.filter((it) => it.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedNames = items.map((it) => it.name.trim())
|
||||||
|
const hasEmpty = trimmedNames.some((n) => !n)
|
||||||
|
const hasDupExisting = trimmedNames.some((n) => existingNames.has(n))
|
||||||
|
const hasDupInList = trimmedNames.some((n, i) => trimmedNames.indexOf(n) !== i)
|
||||||
|
const canSubmit = items.length > 0 && !hasEmpty && !hasDupExisting && !hasDupInList
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
await onUpload(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={`이미지 업로드${items.length > 0 ? ` (${items.length})` : ''}`} maxWidth="max-w-2xl">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="p-6 space-y-4 overflow-y-auto flex-1">
|
||||||
|
<label
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
addFiles(e.dataTransfer.files)
|
||||||
|
}}
|
||||||
|
className="relative rounded-xl border-2 border-dashed cursor-pointer min-h-[120px] flex flex-col items-center justify-center"
|
||||||
|
style={dragOver ? {
|
||||||
|
borderColor: 'var(--selected-border)',
|
||||||
|
background: 'var(--selected-bg)',
|
||||||
|
} : {
|
||||||
|
borderColor: 'var(--dashed-border)',
|
||||||
|
background: 'var(--skeleton-bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-2xl mb-1 opacity-50">📥</div>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>클릭하거나 이미지를 끌어다 놓으세요</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-dim)' }}>여러 개 선택 가능</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={(e) => { addFiles(e.target.files); e.target.value = '' }}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
const trimmed = item.name.trim()
|
||||||
|
const dupExisting = trimmed && existingNames.has(trimmed)
|
||||||
|
const dupInList = trimmed && items.some((it, j) => j !== idx && it.name.trim() === trimmed)
|
||||||
|
const empty = !trimmed
|
||||||
|
const errorMsg = empty ? '이름을 입력해주세요'
|
||||||
|
: dupExisting ? '이미 존재하는 이름입니다'
|
||||||
|
: dupInList ? '같은 이름이 중복됩니다'
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-start gap-3 rounded-lg border p-2"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-3)',
|
||||||
|
borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--panel-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded flex items-center justify-center overflow-hidden shrink-0"
|
||||||
|
style={{ background: 'var(--surface-nested)' }}
|
||||||
|
>
|
||||||
|
{item.preview ? (
|
||||||
|
<img src={item.preview} alt="" className="w-full h-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(e) => updateName(item.id, e.target.value)}
|
||||||
|
className="w-full rounded border px-2 py-1.5 text-sm outline-none"
|
||||||
|
style={{
|
||||||
|
background: 'var(--input-bg)',
|
||||||
|
borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--input-border)',
|
||||||
|
color: 'var(--text-strong)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="text-[11px] px-0.5" style={{ color: 'var(--danger-text)' }}>{errorMsg}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(item.id)}
|
||||||
|
className="w-7 h-7 rounded shrink-0 hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)]"
|
||||||
|
style={{ color: 'var(--text-dim)' }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex gap-2 px-6 py-4 border-t shrink-0"
|
||||||
|
style={{ borderColor: 'var(--panel-border)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 rounded-lg border px-4 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--btn-bg)',
|
||||||
|
borderColor: 'var(--btn-border)',
|
||||||
|
color: 'var(--text-emphasis)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit || uploading}
|
||||||
|
className="flex-1 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-[var(--btn-primary-bg-hover)]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--btn-primary-bg)',
|
||||||
|
color: 'var(--btn-primary-text)',
|
||||||
|
boxShadow: 'var(--btn-primary-shadow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading ? '업로드 중...' : `${items.length > 0 ? `${items.length}개 ` : ''}업로드`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useEffect, useLayoutEffect } from 'react'
|
import { useEffect, useLayoutEffect } from 'react'
|
||||||
import { useQuery, useQueries } from '@tanstack/react-query'
|
import { useQuery, useQueries } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../../api/client'
|
||||||
import { useLayout } from '../../components/Layout'
|
import { useLayout } from '../../../components/pc/Layout'
|
||||||
import CharacterPanel from './user/CharacterPanel'
|
import CharacterPanel from './user/CharacterPanel'
|
||||||
import BossSelector from './user/BossSelector'
|
import BossSelector from './user/BossSelector'
|
||||||
import { useBossStore } from './store'
|
import { useBossStore } from '../store'
|
||||||
|
|
||||||
const MAX_PER_CHARACTER = 12
|
const MAX_PER_CHARACTER = 12
|
||||||
|
|
||||||
|
|
@ -1,37 +1,16 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '../../../api/client'
|
import { api } from '../../../../api/client'
|
||||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
|
||||||
import Checkbox from '../../../components/Checkbox'
|
import Checkbox from '../../../../components/common/Checkbox'
|
||||||
import Select from '../../../components/Select'
|
import Select from '../../../../components/common/Select'
|
||||||
import { useAuthStore } from '../../../stores/auth'
|
import FormField, { formInputClass, formInputStyle } from '../../../../components/common/FormField'
|
||||||
|
import { useAuthStore } from '../../../../stores/auth'
|
||||||
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants'
|
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants'
|
||||||
|
|
||||||
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
||||||
|
|
||||||
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" style={{ color: 'var(--text-emphasis)' }}>
|
|
||||||
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
|
|
||||||
</label>
|
|
||||||
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
|
|
||||||
const inputStyle = {
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
color: 'var(--text-strong)',
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyDifficultyState() {
|
function emptyDifficultyState() {
|
||||||
const obj = {}
|
const obj = {}
|
||||||
DIFFICULTIES.forEach((d) => {
|
DIFFICULTIES.forEach((d) => {
|
||||||
|
|
@ -197,28 +176,28 @@ export default function BossForm() {
|
||||||
>
|
>
|
||||||
{/* 이름 + 최대 인원 */}
|
{/* 이름 + 최대 인원 */}
|
||||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||||
<Field label="보스 이름" required error={errors.name}>
|
<FormField label="보스 이름" required error={errors.name}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="예: 검은 마법사"
|
placeholder="예: 검은 마법사"
|
||||||
className={inputCls}
|
className={formInputClass}
|
||||||
style={inputStyle}
|
style={formInputStyle}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="최대 인원">
|
<FormField label="최대 인원">
|
||||||
<Select
|
<Select
|
||||||
value={maxPartySize}
|
value={maxPartySize}
|
||||||
onChange={setMaxPartySize}
|
onChange={setMaxPartySize}
|
||||||
options={PARTY_OPTIONS}
|
options={PARTY_OPTIONS}
|
||||||
className="w-24"
|
className="w-24"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 이미지 */}
|
{/* 이미지 */}
|
||||||
<Field label="보스 이미지" required={!isEdit} error={errors.image}>
|
<FormField label="보스 이미지" required={!isEdit} error={errors.image}>
|
||||||
<label
|
<label
|
||||||
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
|
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -256,10 +235,10 @@ export default function BossForm() {
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
{/* 난이도 */}
|
{/* 난이도 */}
|
||||||
<Field label="난이도별 결정 정보" required error={errors.difficulties} hint="활성화한 난이도만 저장됩니다">
|
<FormField label="난이도별 결정 정보" required error={errors.difficulties} hint="활성화한 난이도만 저장됩니다">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{DIFFICULTIES.map((d) => {
|
{DIFFICULTIES.map((d) => {
|
||||||
const v = difficulties[d.key]
|
const v = difficulties[d.key]
|
||||||
|
|
@ -304,7 +283,7 @@ export default function BossForm() {
|
||||||
}}
|
}}
|
||||||
disabled={!v.enabled}
|
disabled={!v.enabled}
|
||||||
placeholder="결정 가격"
|
placeholder="결정 가격"
|
||||||
className="w-full rounded-lg border pl-4 pr-28 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full rounded-lg border pl-4 pr-28 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] disabled:opacity-50"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--input-bg)',
|
background: 'var(--input-bg)',
|
||||||
borderColor: priceErr ? 'var(--icon-danger-border)' : 'var(--input-border)',
|
borderColor: priceErr ? 'var(--icon-danger-border)' : 'var(--input-border)',
|
||||||
|
|
@ -326,7 +305,7 @@ export default function BossForm() {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
|
|
@ -10,8 +10,8 @@ import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { api } from '../../../api/client'
|
import { api } from '../../../../api/client'
|
||||||
import Tooltip from '../../../components/Tooltip'
|
import Tooltip from '../../../../components/common/Tooltip'
|
||||||
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants'
|
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants'
|
||||||
|
|
||||||
function BossCardContent({ boss, dragging = false }) {
|
function BossCardContent({ boss, dragging = false }) {
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { DIFFICULTIES, getDifficultyBadgeStyle, formatMeso, getDifficultyImageUrl } from '../constants'
|
||||||
|
|
||||||
|
describe('DIFFICULTIES', () => {
|
||||||
|
it('5개 난이도 (easy, normal, hard, chaos, extreme)', () => {
|
||||||
|
expect(DIFFICULTIES.map((d) => d.key)).toEqual(['easy', 'normal', 'hard', 'chaos', 'extreme'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('모든 항목에 key/label/initial/colors 존재', () => {
|
||||||
|
DIFFICULTIES.forEach((d) => {
|
||||||
|
expect(d.key).toBeTruthy()
|
||||||
|
expect(d.label).toBeTruthy()
|
||||||
|
expect(d.initial).toBeTruthy()
|
||||||
|
expect(d.colors).toHaveProperty('bg')
|
||||||
|
expect(d.colors).toHaveProperty('border')
|
||||||
|
expect(d.colors).toHaveProperty('text')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDifficultyBadgeStyle', () => {
|
||||||
|
it('난이도 객체를 CSS 스타일로 변환', () => {
|
||||||
|
const s = getDifficultyBadgeStyle('easy')
|
||||||
|
expect(s.backgroundColor).toBe('#999999')
|
||||||
|
expect(s.borderColor).toBe('#999999')
|
||||||
|
expect(s.color).toBe('#ffffff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('없는 key는 빈 객체', () => {
|
||||||
|
expect(getDifficultyBadgeStyle('invalid')).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatMeso (re-export from utils)', () => {
|
||||||
|
it('utils/formatting의 formatMeso와 동일 동작', () => {
|
||||||
|
expect(formatMeso(0)).toBe('0')
|
||||||
|
expect(formatMeso(100_010_000)).toBe('1억 1만')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDifficultyImageUrl', () => {
|
||||||
|
it('S3 경로 규칙대로 URL 반환', () => {
|
||||||
|
expect(getDifficultyImageUrl('easy'))
|
||||||
|
.toBe('https://s3.caadiq.co.kr/maplestory/crystal/difficulty/easy.webp')
|
||||||
|
expect(getDifficultyImageUrl('chaos'))
|
||||||
|
.toBe('https://s3.caadiq.co.kr/maplestory/crystal/difficulty/chaos.webp')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -32,15 +32,8 @@ export function getDifficultyBadgeStyle(key) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMeso(n) {
|
// formatMeso는 utils/formatting 에서 재-export (모든 기능 공통)
|
||||||
if (!n || n < 10000) return (n || 0).toLocaleString()
|
export { formatMeso } from '../../../../utils/formatting'
|
||||||
if (n >= 100_000_000) {
|
|
||||||
const uk = Math.floor(n / 100_000_000)
|
|
||||||
const man = Math.floor((n % 100_000_000) / 10_000)
|
|
||||||
return man > 0 ? `${uk}억 ${man.toLocaleString()}만` : `${uk}억`
|
|
||||||
}
|
|
||||||
return `${Math.floor(n / 10_000).toLocaleString()}만`
|
|
||||||
}
|
|
||||||
|
|
||||||
// difficulty 이미지 URL (S3)
|
// difficulty 이미지 URL (S3)
|
||||||
export const DIFFICULTY_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/crystal/difficulty'
|
export const DIFFICULTY_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/crystal/difficulty'
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Select from '../../../components/Select'
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
||||||
|
import Select from '../../../../components/common/Select'
|
||||||
import { DIFFICULTIES, formatMeso } from '../admin/constants'
|
import { DIFFICULTIES, formatMeso } from '../admin/constants'
|
||||||
|
|
||||||
const LABEL_EN = { easy: 'EASY', normal: 'NORMAL', hard: 'HARD', chaos: 'CHAOS', extreme: 'EXTREME' }
|
const LABEL_EN = { easy: 'EASY', normal: 'NORMAL', hard: 'HARD', chaos: 'CHAOS', extreme: 'EXTREME' }
|
||||||
|
|
@ -45,7 +46,7 @@ export default function BossSelector({ characterName, bosses, selections, onChan
|
||||||
>
|
>
|
||||||
{/* 헤더 (고정) */}
|
{/* 헤더 (고정) */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 px-3 py-3 border-b text-base font-medium shrink-0"
|
className="flex items-center gap-3 px-5 py-3 border-b text-base font-medium shrink-0"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--surface-2)',
|
background: 'var(--surface-2)',
|
||||||
borderColor: 'var(--panel-border)',
|
borderColor: 'var(--panel-border)',
|
||||||
|
|
@ -58,8 +59,15 @@ export default function BossSelector({ characterName, bosses, selections, onChan
|
||||||
<div className="w-32 shrink-0 text-right">가격</div>
|
<div className="w-32 shrink-0 text-right">가격</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 목록 (스크롤) */}
|
{/* 목록 (스크롤) */}
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<OverlayScrollbarsComponent
|
||||||
<div className="divide-y" style={{ '--tw-divide-opacity': 1 }}>
|
className="flex-1 min-h-0"
|
||||||
|
options={{
|
||||||
|
scrollbars: { theme: 'os-theme-maple os-theme-dark', autoHide: 'leave', autoHideDelay: 800 },
|
||||||
|
overflow: { x: 'hidden', y: 'scroll' },
|
||||||
|
}}
|
||||||
|
defer
|
||||||
|
>
|
||||||
|
<div className="divide-y px-2" style={{ '--tw-divide-opacity': 1 }}>
|
||||||
{bosses.map((boss) => {
|
{bosses.map((boss) => {
|
||||||
const availableDiffs = DIFFICULTIES.filter((d) =>
|
const availableDiffs = DIFFICULTIES.filter((d) =>
|
||||||
boss.difficulties.some((bd) => bd.difficulty === d.key)
|
boss.difficulties.some((bd) => bd.difficulty === d.key)
|
||||||
|
|
@ -161,7 +169,7 @@ export default function BossSelector({ characterName, bosses, selections, onChan
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</OverlayScrollbarsComponent>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -2,11 +2,11 @@ import { useState } from 'react'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { Reorder, useDragControls } from 'framer-motion'
|
import { Reorder, useDragControls } from 'framer-motion'
|
||||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
||||||
import { api } from '../../../api/client'
|
import { api } from '../../../../api/client'
|
||||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
|
||||||
import Tooltip from '../../../components/Tooltip'
|
import Tooltip from '../../../../components/common/Tooltip'
|
||||||
import CharacterSuggestDropdown from '../../../components/CharacterSuggestDropdown'
|
import CharacterSuggestDropdown from '../../../../components/common/CharacterSuggestDropdown'
|
||||||
import { useFitText } from '../../../hooks/useFitText'
|
import { useFitText } from '../../../../hooks/useFitText'
|
||||||
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
|
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
|
||||||
|
|
||||||
const MAX_PER_CHARACTER = 12
|
const MAX_PER_CHARACTER = 12
|
||||||
|
|
@ -1,458 +0,0 @@
|
||||||
import { useState, useEffect, useLayoutEffect } from 'react'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import { api } from '../../api/client'
|
|
||||||
import {
|
|
||||||
GENESIS_CHAPTERS,
|
|
||||||
GENESIS_TOTAL,
|
|
||||||
WEEKLY_BOSSES,
|
|
||||||
MONTHLY_BOSSES,
|
|
||||||
calcPoints,
|
|
||||||
formatDate,
|
|
||||||
todayKST,
|
|
||||||
} from './data'
|
|
||||||
import { useLiberationStore } from './store'
|
|
||||||
import QuestSelector from './components/QuestSelector'
|
|
||||||
import PointsInput from './components/PointsInput'
|
|
||||||
import ProgressBar from './components/ProgressBar'
|
|
||||||
import WeeklyDefault from './components/WeeklyDefault'
|
|
||||||
import DatePicker from '../../components/DatePicker'
|
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
|
||||||
import { useLayout } from '../../components/Layout'
|
|
||||||
|
|
||||||
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 bossEarn(boss, sel) {
|
|
||||||
if (!sel) return 0
|
|
||||||
const d = boss.difficulties.find((x) => x.key === sel.difficulty)
|
|
||||||
if (!d) return 0
|
|
||||||
return calcPoints(d.points, sel.party)
|
|
||||||
}
|
|
||||||
|
|
||||||
function calcWeekPoints(weekData) {
|
|
||||||
let points = 0
|
|
||||||
WEEKLY_BOSSES.forEach((b) => {
|
|
||||||
points += bossEarn(b, weekData.bosses[b.key])
|
|
||||||
})
|
|
||||||
return points
|
|
||||||
}
|
|
||||||
|
|
||||||
function calcDoneEarn(weekData) {
|
|
||||||
let points = 0
|
|
||||||
WEEKLY_BOSSES.forEach((b) => {
|
|
||||||
const sel = weekData.bosses[b.key]
|
|
||||||
if (sel?.done) points += bossEarn(b, sel)
|
|
||||||
})
|
|
||||||
return points
|
|
||||||
}
|
|
||||||
|
|
||||||
function calcMonthlyEarn(weekData) {
|
|
||||||
return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default function Liberation() {
|
|
||||||
const { setFullscreen } = useLayout()
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setFullscreen(true)
|
|
||||||
return () => setFullscreen(false)
|
|
||||||
}, [setFullscreen])
|
|
||||||
|
|
||||||
const [liberationType, setLiberationType] = useState('genesis') // 'genesis' | 'destiny'
|
|
||||||
|
|
||||||
const genesisImg = useQuery({
|
|
||||||
queryKey: ['image', '제네시스 스태프'],
|
|
||||||
queryFn: () => api('/api/images/' + encodeURIComponent('제네시스 스태프')).catch(() => null),
|
|
||||||
staleTime: Infinity,
|
|
||||||
})
|
|
||||||
const destinyImg = useQuery({
|
|
||||||
queryKey: ['image', '데스티니 스태프'],
|
|
||||||
queryFn: () => api('/api/images/' + encodeURIComponent('데스티니 스태프')).catch(() => null),
|
|
||||||
staleTime: Infinity,
|
|
||||||
})
|
|
||||||
|
|
||||||
const calcMode = useLiberationStore((s) => s.calcMode)
|
|
||||||
const state = useLiberationStore((s) => s[s.calcMode])
|
|
||||||
const setCalcMode = useLiberationStore((s) => s.setCalcMode)
|
|
||||||
const updateSlot = useLiberationStore((s) => s.updateSlot)
|
|
||||||
const resetSlot = useLiberationStore((s) => s.resetSlot)
|
|
||||||
const setState = (updater) => updateSlot(updater)
|
|
||||||
|
|
||||||
// 포인트 이월 계산: 현재 퀘스트의 required를 초과하면 자동으로 다음 퀘스트로 넘어감
|
|
||||||
const priorConsumed = GENESIS_CHAPTERS
|
|
||||||
.slice(0, state.startChapter)
|
|
||||||
.reduce((s, c) => s + c.required, 0)
|
|
||||||
let cascadeIdx = state.startChapter
|
|
||||||
let cascadeRemain = state.currentPoints
|
|
||||||
let cascadeConsumed = 0
|
|
||||||
while (cascadeIdx < GENESIS_CHAPTERS.length && cascadeRemain >= GENESIS_CHAPTERS[cascadeIdx].required) {
|
|
||||||
cascadeConsumed += GENESIS_CHAPTERS[cascadeIdx].required
|
|
||||||
cascadeRemain -= GENESIS_CHAPTERS[cascadeIdx].required
|
|
||||||
cascadeIdx++
|
|
||||||
}
|
|
||||||
const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain
|
|
||||||
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
|
|
||||||
const weeklyEarn = calcWeekPoints(state.weekly)
|
|
||||||
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
|
|
||||||
// 주간/월간 분리
|
|
||||||
const doneEarn = calcDoneEarn(state.weekly)
|
|
||||||
const monthlyEarn = calcMonthlyEarn(state.weekly)
|
|
||||||
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
|
|
||||||
|
|
||||||
// 주차별 모드에서는 모든 주차 합산 (검은 마법사는 월별 슬롯 1회만 카운트)
|
|
||||||
const headerWeekly = calcMode === 'weekly'
|
|
||||||
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
|
|
||||||
: weeklyEarn
|
|
||||||
const headerMonthly = (() => {
|
|
||||||
if (calcMode !== 'weekly') return monthlyEarn
|
|
||||||
const sw = state.schedulerWeeks || []
|
|
||||||
if (!state.startDate) return 0
|
|
||||||
const claimed = {}
|
|
||||||
sw.forEach((w, idx) => {
|
|
||||||
const diff = w.config.blackMage?.difficulty
|
|
||||||
if (!diff || diff === 'none') return
|
|
||||||
const r = (() => {
|
|
||||||
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 (remaining <= 0) return dayjs(state.startDate).tz('Asia/Seoul').startOf('day').toDate()
|
|
||||||
|
|
||||||
const startKST = dayjs(state.startDate).tz('Asia/Seoul').startOf('day')
|
|
||||||
const events = []
|
|
||||||
|
|
||||||
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')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 검은 마법사: 슬롯 배정에 따라 해당 주차의 첫날(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 })
|
|
||||||
})
|
|
||||||
|
|
||||||
// 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용
|
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
events.sort((a, b) => a.date.diff(b.date))
|
|
||||||
let cumulative = 0
|
|
||||||
for (const e of events) {
|
|
||||||
cumulative += e.amount
|
|
||||||
if (cumulative >= remaining) return e.date.toDate()
|
|
||||||
}
|
|
||||||
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 [resetOpen, setResetOpen] = useState(false)
|
|
||||||
const doReset = () => {
|
|
||||||
resetSlot()
|
|
||||||
setResetOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 pb-10">
|
|
||||||
{/* 해방 종류 탭 */}
|
|
||||||
<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) => {
|
|
||||||
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-3xl mx-auto rounded-2xl border p-16 text-center space-y-3 flex flex-col items-center justify-center"
|
|
||||||
style={{
|
|
||||||
minHeight: 'calc(100vh - 220px)',
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
boxShadow: 'var(--panel-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-2xl font-bold" style={{ color: 'var(--text-emphasis)' }}>구현 예정</div>
|
|
||||||
<div className="text-sm" style={{ color: 'var(--text-dim)' }}>데스티니 해방 계산기는 준비 중입니다.</div>
|
|
||||||
</div>
|
|
||||||
) : (<>
|
|
||||||
{/* 계산 모드 탭 */}
|
|
||||||
<div
|
|
||||||
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
|
|
||||||
style={{
|
|
||||||
background: 'var(--surface-3)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ key: 'simple', label: '단순 계산' },
|
|
||||||
{ key: 'weekly', label: '주차별 계산' },
|
|
||||||
].map((t) => {
|
|
||||||
const active = calcMode === t.key
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={t.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCalcMode(t.key)}
|
|
||||||
className="flex-1 h-10 rounded-lg text-sm font-semibold"
|
|
||||||
style={active ? {
|
|
||||||
background: 'var(--selected-bg)',
|
|
||||||
color: 'var(--accent-bright)',
|
|
||||||
} : {
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
startChapter={state.startChapter}
|
|
||||||
currentPoints={state.currentPoints}
|
|
||||||
completionDate={isDone ? formatDate(completionDate) : null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 현재 진행 상태 입력 */}
|
|
||||||
<div
|
|
||||||
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
boxShadow: 'var(--panel-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 grid-cols-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
|
|
||||||
<DatePicker
|
|
||||||
value={formatDate(state.startDate)}
|
|
||||||
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
|
|
||||||
<QuestSelector
|
|
||||||
value={state.startChapter}
|
|
||||||
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 흔적</label>
|
|
||||||
<div
|
|
||||||
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
|
||||||
style={{
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PointsInput
|
|
||||||
value={state.currentPoints}
|
|
||||||
max={3000}
|
|
||||||
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
|
|
||||||
className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
|
|
||||||
style={{ color: 'var(--text-strong)' }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="flex items-center px-3 text-base border-l select-none tabular-nums"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
color: 'var(--text-dim)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
/ {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<WeeklyDefault
|
|
||||||
weekly={state.weekly}
|
|
||||||
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
|
||||||
totalWeekly={headerWeekly}
|
|
||||||
totalMonthly={headerMonthly}
|
|
||||||
remaining={remaining}
|
|
||||||
mode={calcMode}
|
|
||||||
startDate={state.startDate}
|
|
||||||
weeks={state.schedulerWeeks}
|
|
||||||
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setResetOpen(true)}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--icon-danger-border)',
|
|
||||||
background: 'var(--icon-danger-bg)',
|
|
||||||
color: 'var(--danger-text)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
||||||
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
전체 초기화
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>)}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={resetOpen}
|
|
||||||
onClose={() => setResetOpen(false)}
|
|
||||||
onConfirm={doReset}
|
|
||||||
title="전체 초기화"
|
|
||||||
description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
|
|
||||||
confirmText="초기화"
|
|
||||||
destructive
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
227
frontend/src/features/liberation/__tests__/utils.test.js
Normal file
227
frontend/src/features/liberation/__tests__/utils.test.js
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import utc from 'dayjs/plugin/utc'
|
||||||
|
import timezone from 'dayjs/plugin/timezone'
|
||||||
|
import {
|
||||||
|
bossEarn,
|
||||||
|
calcWeekPoints,
|
||||||
|
calcDoneEarn,
|
||||||
|
calcMonthlyEarn,
|
||||||
|
getSchedulerWeekRange,
|
||||||
|
computeCompletionDate,
|
||||||
|
} from '../utils'
|
||||||
|
import { calcPoints, WEEKLY_BOSSES } from '../data'
|
||||||
|
import { makeEmptyWeekly } from '../store'
|
||||||
|
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
|
||||||
|
describe('calcPoints', () => {
|
||||||
|
it('파티원 수로 나누고 버림', () => {
|
||||||
|
expect(calcPoints(100, 1)).toBe(100)
|
||||||
|
expect(calcPoints(100, 3)).toBe(33)
|
||||||
|
expect(calcPoints(50, 2)).toBe(25)
|
||||||
|
expect(calcPoints(7, 2)).toBe(3) // Math.floor(3.5) = 3
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bossEarn', () => {
|
||||||
|
const suu = WEEKLY_BOSSES.find((b) => b.key === 'lotus') // 스우
|
||||||
|
|
||||||
|
it('선택 없음이면 0', () => {
|
||||||
|
expect(bossEarn(suu, null)).toBe(0)
|
||||||
|
expect(bossEarn(suu, undefined)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('존재하지 않는 난이도면 0', () => {
|
||||||
|
expect(bossEarn(suu, { difficulty: 'invalid', party: 1 })).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('난이도별 점수를 파티 수로 분배', () => {
|
||||||
|
// 스우 하드 = 50점
|
||||||
|
expect(bossEarn(suu, { difficulty: 'hard', party: 1 })).toBe(50)
|
||||||
|
expect(bossEarn(suu, { difficulty: 'hard', party: 2 })).toBe(25)
|
||||||
|
expect(bossEarn(suu, { difficulty: 'hard', party: 3 })).toBe(16) // floor(50/3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calcWeekPoints / calcDoneEarn', () => {
|
||||||
|
it('빈 주간 설정은 0', () => {
|
||||||
|
const empty = makeEmptyWeekly()
|
||||||
|
expect(calcWeekPoints(empty)).toBe(0)
|
||||||
|
expect(calcDoneEarn(empty)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('선택된 보스의 점수 합산', () => {
|
||||||
|
const cfg = makeEmptyWeekly()
|
||||||
|
cfg.bosses.lotus = { difficulty: 'hard', party: 1, done: false } // 50
|
||||||
|
cfg.bosses.damien = { difficulty: 'normal', party: 1, done: false } // 10
|
||||||
|
expect(calcWeekPoints(cfg)).toBe(60)
|
||||||
|
expect(calcDoneEarn(cfg)).toBe(0) // 완료 없음
|
||||||
|
})
|
||||||
|
|
||||||
|
it('done=true인 것만 calcDoneEarn 합산', () => {
|
||||||
|
const cfg = makeEmptyWeekly()
|
||||||
|
cfg.bosses.lotus = { difficulty: 'hard', party: 1, done: true }
|
||||||
|
cfg.bosses.damien = { difficulty: 'normal', party: 1, done: false }
|
||||||
|
expect(calcWeekPoints(cfg)).toBe(60)
|
||||||
|
expect(calcDoneEarn(cfg)).toBe(50)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calcMonthlyEarn', () => {
|
||||||
|
it('검은 마법사 난이도별 점수', () => {
|
||||||
|
const cfg = makeEmptyWeekly()
|
||||||
|
expect(calcMonthlyEarn(cfg)).toBe(0) // 기본은 none
|
||||||
|
|
||||||
|
cfg.blackMage = { difficulty: 'hard', party: 1, done: false }
|
||||||
|
expect(calcMonthlyEarn(cfg)).toBe(600)
|
||||||
|
|
||||||
|
cfg.blackMage = { difficulty: 'hard', party: 2, done: false }
|
||||||
|
expect(calcMonthlyEarn(cfg)).toBe(300)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSchedulerWeekRange', () => {
|
||||||
|
it('1주차는 시작일부터 다음 목요일 전날까지', () => {
|
||||||
|
// 2026-04-19 일요일 시작 → 다음 목요일 2026-04-23
|
||||||
|
const r = getSchedulerWeekRange('2026-04-19T00:00:00+09:00', 1)
|
||||||
|
expect(r.start.format('YYYY-MM-DD')).toBe('2026-04-19')
|
||||||
|
expect(r.end.format('YYYY-MM-DD')).toBe('2026-04-22') // 목요일 전날 (수요일)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2주차는 다음 목요일부터 7일', () => {
|
||||||
|
const r = getSchedulerWeekRange('2026-04-19T00:00:00+09:00', 2)
|
||||||
|
expect(r.start.format('YYYY-MM-DD')).toBe('2026-04-23')
|
||||||
|
expect(r.end.format('YYYY-MM-DD')).toBe('2026-04-29')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('목요일에 시작하면 그 목요일이 1주차', () => {
|
||||||
|
// 2026-04-23 목요일 시작
|
||||||
|
const r1 = getSchedulerWeekRange('2026-04-23T00:00:00+09:00', 1)
|
||||||
|
expect(r1.start.format('YYYY-MM-DD')).toBe('2026-04-23')
|
||||||
|
// 1주차 end는 다음 목요일(4/30) 전날 = 4/29
|
||||||
|
expect(r1.end.format('YYYY-MM-DD')).toBe('2026-04-29')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeCompletionDate (단순 계산)', () => {
|
||||||
|
const baseParams = {
|
||||||
|
calcMode: 'simple',
|
||||||
|
alreadyDone: false,
|
||||||
|
monthlyDoneThisMonth: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('alreadyDone이면 오늘 반환', () => {
|
||||||
|
const r = computeCompletionDate({
|
||||||
|
...baseParams,
|
||||||
|
alreadyDone: true,
|
||||||
|
state: { startDate: '2026-04-19T00:00:00+09:00' },
|
||||||
|
remaining: 0,
|
||||||
|
weeklyEarn: 0,
|
||||||
|
doneEarn: 0,
|
||||||
|
monthlyEarn: 0,
|
||||||
|
})
|
||||||
|
expect(r).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remaining=0이면 시작일 반환', () => {
|
||||||
|
const r = computeCompletionDate({
|
||||||
|
...baseParams,
|
||||||
|
state: { startDate: '2026-04-19T00:00:00+09:00' },
|
||||||
|
remaining: 0,
|
||||||
|
weeklyEarn: 100,
|
||||||
|
doneEarn: 0,
|
||||||
|
monthlyEarn: 0,
|
||||||
|
})
|
||||||
|
expect(r).toBeInstanceOf(Date)
|
||||||
|
expect(dayjs(r).tz('Asia/Seoul').format('YYYY-MM-DD')).toBe('2026-04-19')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weeklyEarn=0, monthlyEarn=0이면 null', () => {
|
||||||
|
const r = computeCompletionDate({
|
||||||
|
...baseParams,
|
||||||
|
state: { startDate: '2026-04-19T00:00:00+09:00' },
|
||||||
|
remaining: 1000,
|
||||||
|
weeklyEarn: 0,
|
||||||
|
doneEarn: 0,
|
||||||
|
monthlyEarn: 0,
|
||||||
|
})
|
||||||
|
expect(r).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('주 100점 · 6500점 필요 → 약 65주 후 완료', () => {
|
||||||
|
const r = computeCompletionDate({
|
||||||
|
...baseParams,
|
||||||
|
state: { startDate: '2026-04-19T00:00:00+09:00' }, // 일요일
|
||||||
|
remaining: 6500,
|
||||||
|
weeklyEarn: 100,
|
||||||
|
doneEarn: 0,
|
||||||
|
monthlyEarn: 0,
|
||||||
|
})
|
||||||
|
expect(r).toBeInstanceOf(Date)
|
||||||
|
// 시작 2026-04-19 + 65주 = 2027-07-22 전후 (약 1년 3개월)
|
||||||
|
const days = dayjs(r).diff(dayjs('2026-04-19T00:00:00+09:00'), 'day')
|
||||||
|
expect(days).toBeGreaterThan(400)
|
||||||
|
expect(days).toBeLessThan(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('월간 검은 마법사도 반영', () => {
|
||||||
|
// 주간 0, 월간 600, remaining 1200 → 2회 검마 필요 → 2개월 후
|
||||||
|
const r = computeCompletionDate({
|
||||||
|
...baseParams,
|
||||||
|
state: { startDate: '2026-04-19T00:00:00+09:00' },
|
||||||
|
remaining: 1200,
|
||||||
|
weeklyEarn: 0,
|
||||||
|
doneEarn: 0,
|
||||||
|
monthlyEarn: 600,
|
||||||
|
monthlyDoneThisMonth: false,
|
||||||
|
})
|
||||||
|
expect(r).toBeInstanceOf(Date)
|
||||||
|
// 시작 당일 1회 + 다음달 1회 = 2개월 후
|
||||||
|
expect(dayjs(r).tz('Asia/Seoul').format('YYYY-MM-DD')).toBe('2026-05-01')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeCompletionDate (주차별 계산)', () => {
|
||||||
|
it('schedulerWeeks가 비어있으면 null', () => {
|
||||||
|
const r = computeCompletionDate({
|
||||||
|
calcMode: 'weekly',
|
||||||
|
state: { startDate: '2026-04-19T00:00:00+09:00', schedulerWeeks: [] },
|
||||||
|
alreadyDone: false,
|
||||||
|
remaining: 1000,
|
||||||
|
weeklyEarn: 0,
|
||||||
|
doneEarn: 0,
|
||||||
|
monthlyEarn: 0,
|
||||||
|
monthlyDoneThisMonth: false,
|
||||||
|
})
|
||||||
|
expect(r).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1주차 + 2주차 설정이 서로 다르게 적립', () => {
|
||||||
|
const week1 = makeEmptyWeekly()
|
||||||
|
week1.bosses.lotus = { difficulty: 'hard', party: 1, done: false } // 50
|
||||||
|
const week2 = makeEmptyWeekly()
|
||||||
|
week2.bosses.lotus = { difficulty: 'hard', party: 1, done: false }
|
||||||
|
week2.bosses.damien = { difficulty: 'hard', party: 1, done: false } // +50 = 100
|
||||||
|
|
||||||
|
const r = computeCompletionDate({
|
||||||
|
calcMode: 'weekly',
|
||||||
|
state: {
|
||||||
|
startDate: '2026-04-19T00:00:00+09:00',
|
||||||
|
schedulerWeeks: [
|
||||||
|
{ id: 1, config: week1 },
|
||||||
|
{ id: 2, config: week2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
alreadyDone: false,
|
||||||
|
remaining: 6500,
|
||||||
|
weeklyEarn: 0,
|
||||||
|
doneEarn: 0,
|
||||||
|
monthlyEarn: 0,
|
||||||
|
monthlyDoneThisMonth: false,
|
||||||
|
})
|
||||||
|
// 1주차 50 + 2주차+ 매주 100 → 6500 도달까지 대략 65주
|
||||||
|
expect(r).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
})
|
||||||
299
frontend/src/features/liberation/pc/Liberation.jsx
Normal file
299
frontend/src/features/liberation/pc/Liberation.jsx
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
import { useState, useLayoutEffect, useMemo } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { api } from '../../../api/client'
|
||||||
|
import {
|
||||||
|
GENESIS_CHAPTERS,
|
||||||
|
GENESIS_TOTAL,
|
||||||
|
MONTHLY_BOSSES,
|
||||||
|
formatDate,
|
||||||
|
} from '../data'
|
||||||
|
import { useLiberationStore } from '../store'
|
||||||
|
import {
|
||||||
|
bossEarn,
|
||||||
|
calcWeekPoints,
|
||||||
|
calcDoneEarn,
|
||||||
|
calcMonthlyEarn,
|
||||||
|
getSchedulerWeekRange,
|
||||||
|
computeCompletionDate,
|
||||||
|
} from '../utils'
|
||||||
|
import QuestSelector from './components/QuestSelector'
|
||||||
|
import PointsInput from './components/PointsInput'
|
||||||
|
import ProgressBar from './components/ProgressBar'
|
||||||
|
import WeeklyDefault from './components/WeeklyDefault'
|
||||||
|
import DatePicker from '../../../components/common/DatePicker'
|
||||||
|
import ConfirmDialog from '../../../components/common/ConfirmDialog'
|
||||||
|
import { useLayout } from '../../../components/pc/Layout'
|
||||||
|
|
||||||
|
export default function Liberation() {
|
||||||
|
const { setFullscreen } = useLayout()
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setFullscreen(true)
|
||||||
|
return () => setFullscreen(false)
|
||||||
|
}, [setFullscreen])
|
||||||
|
|
||||||
|
const [liberationType, setLiberationType] = useState('genesis') // 'genesis' | 'destiny'
|
||||||
|
|
||||||
|
const genesisImg = useQuery({
|
||||||
|
queryKey: ['image', '제네시스 스태프'],
|
||||||
|
queryFn: () => api('/api/images/' + encodeURIComponent('제네시스 스태프')).catch(() => null),
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
const destinyImg = useQuery({
|
||||||
|
queryKey: ['image', '데스티니 스태프'],
|
||||||
|
queryFn: () => api('/api/images/' + encodeURIComponent('데스티니 스태프')).catch(() => null),
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
|
const calcMode = useLiberationStore((s) => s.calcMode)
|
||||||
|
const state = useLiberationStore((s) => s[s.calcMode])
|
||||||
|
const setCalcMode = useLiberationStore((s) => s.setCalcMode)
|
||||||
|
const updateSlot = useLiberationStore((s) => s.updateSlot)
|
||||||
|
const resetSlot = useLiberationStore((s) => s.resetSlot)
|
||||||
|
const setState = (updater) => updateSlot(updater)
|
||||||
|
|
||||||
|
// 포인트 이월: 현재 퀘스트 required를 초과하면 자동으로 다음 퀘스트로 넘어감
|
||||||
|
const priorConsumed = GENESIS_CHAPTERS
|
||||||
|
.slice(0, state.startChapter)
|
||||||
|
.reduce((s, c) => s + c.required, 0)
|
||||||
|
let cascadeIdx = state.startChapter
|
||||||
|
let cascadeRemain = state.currentPoints
|
||||||
|
let cascadeConsumed = 0
|
||||||
|
while (cascadeIdx < GENESIS_CHAPTERS.length && cascadeRemain >= GENESIS_CHAPTERS[cascadeIdx].required) {
|
||||||
|
cascadeConsumed += GENESIS_CHAPTERS[cascadeIdx].required
|
||||||
|
cascadeRemain -= GENESIS_CHAPTERS[cascadeIdx].required
|
||||||
|
cascadeIdx++
|
||||||
|
}
|
||||||
|
const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain
|
||||||
|
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
|
||||||
|
const weeklyEarn = calcWeekPoints(state.weekly)
|
||||||
|
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
|
||||||
|
const doneEarn = calcDoneEarn(state.weekly)
|
||||||
|
const monthlyEarn = calcMonthlyEarn(state.weekly)
|
||||||
|
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
|
||||||
|
|
||||||
|
// 주차별 모드 헤더 합산 (검은 마법사는 월별 슬롯 1회만 카운트)
|
||||||
|
const headerWeekly = calcMode === 'weekly'
|
||||||
|
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
|
||||||
|
: weeklyEarn
|
||||||
|
const headerMonthly = (() => {
|
||||||
|
if (calcMode !== 'weekly') return monthlyEarn
|
||||||
|
const sw = state.schedulerWeeks || []
|
||||||
|
if (!state.startDate) return 0
|
||||||
|
const claimed = {}
|
||||||
|
sw.forEach((w, idx) => {
|
||||||
|
const diff = w.config.blackMage?.difficulty
|
||||||
|
if (!diff || diff === 'none') return
|
||||||
|
const r = getSchedulerWeekRange(state.startDate, idx + 1)
|
||||||
|
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
|
||||||
|
for (const m of months) {
|
||||||
|
if (!(m in claimed)) {
|
||||||
|
claimed[m] = bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Object.values(claimed).reduce((s, v) => s + v, 0)
|
||||||
|
})()
|
||||||
|
|
||||||
|
const completionDate = useMemo(
|
||||||
|
() => computeCompletionDate({
|
||||||
|
calcMode, state, alreadyDone, remaining,
|
||||||
|
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
|
||||||
|
}),
|
||||||
|
[calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth],
|
||||||
|
)
|
||||||
|
const isDone = completionDate !== null
|
||||||
|
|
||||||
|
const [resetOpen, setResetOpen] = useState(false)
|
||||||
|
const doReset = () => {
|
||||||
|
resetSlot()
|
||||||
|
setResetOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-10">
|
||||||
|
{/* 해방 종류 탭 */}
|
||||||
|
<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) => {
|
||||||
|
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-3xl mx-auto rounded-2xl border p-16 text-center space-y-3 flex flex-col items-center justify-center"
|
||||||
|
style={{
|
||||||
|
minHeight: 'calc(100vh - 220px)',
|
||||||
|
background: 'var(--panel-bg)',
|
||||||
|
borderColor: 'var(--panel-border)',
|
||||||
|
boxShadow: 'var(--panel-shadow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-2xl font-bold" style={{ color: 'var(--text-emphasis)' }}>구현 예정</div>
|
||||||
|
<div className="text-sm" style={{ color: 'var(--text-dim)' }}>데스티니 해방 계산기는 준비 중입니다.</div>
|
||||||
|
</div>
|
||||||
|
) : (<>
|
||||||
|
{/* 계산 모드 탭 */}
|
||||||
|
<div
|
||||||
|
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-3)',
|
||||||
|
borderColor: 'var(--panel-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ key: 'simple', label: '단순 계산' },
|
||||||
|
{ key: 'weekly', label: '주차별 계산' },
|
||||||
|
].map((t) => {
|
||||||
|
const active = calcMode === t.key
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCalcMode(t.key)}
|
||||||
|
className="flex-1 h-10 rounded-lg text-sm font-semibold"
|
||||||
|
style={active ? {
|
||||||
|
background: 'var(--selected-bg)',
|
||||||
|
color: 'var(--accent-bright)',
|
||||||
|
} : {
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
startChapter={state.startChapter}
|
||||||
|
currentPoints={state.currentPoints}
|
||||||
|
completionDate={isDone ? formatDate(completionDate) : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 현재 진행 상태 입력 */}
|
||||||
|
<div
|
||||||
|
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
|
||||||
|
style={{
|
||||||
|
background: 'var(--panel-bg)',
|
||||||
|
borderColor: 'var(--panel-border)',
|
||||||
|
boxShadow: 'var(--panel-shadow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 grid-cols-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
|
||||||
|
<DatePicker
|
||||||
|
value={formatDate(state.startDate)}
|
||||||
|
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
|
||||||
|
<QuestSelector
|
||||||
|
value={state.startChapter}
|
||||||
|
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 흔적</label>
|
||||||
|
<div
|
||||||
|
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
||||||
|
style={{
|
||||||
|
background: 'var(--input-bg)',
|
||||||
|
borderColor: 'var(--input-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PointsInput
|
||||||
|
value={state.currentPoints}
|
||||||
|
max={3000}
|
||||||
|
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
|
||||||
|
className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
|
||||||
|
style={{ color: 'var(--text-strong)' }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="flex items-center px-3 text-base border-l select-none tabular-nums"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--input-border)',
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/ {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WeeklyDefault
|
||||||
|
weekly={state.weekly}
|
||||||
|
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
||||||
|
totalWeekly={headerWeekly}
|
||||||
|
totalMonthly={headerMonthly}
|
||||||
|
remaining={remaining}
|
||||||
|
mode={calcMode}
|
||||||
|
startDate={state.startDate}
|
||||||
|
weeks={state.schedulerWeeks}
|
||||||
|
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResetOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--icon-danger-border)',
|
||||||
|
background: 'var(--icon-danger-bg)',
|
||||||
|
color: 'var(--danger-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
전체 초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={resetOpen}
|
||||||
|
onClose={() => setResetOpen(false)}
|
||||||
|
onConfirm={doReset}
|
||||||
|
title="전체 초기화"
|
||||||
|
description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
|
||||||
|
confirmText="초기화"
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../data'
|
import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../../data'
|
||||||
|
|
||||||
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
||||||
function formatKoreanDate(s) {
|
function formatKoreanDate(s) {
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { GENESIS_CHAPTERS, QUEST_BOSS_IMAGE_BASE } from '../data'
|
import { GENESIS_CHAPTERS, QUEST_BOSS_IMAGE_BASE } from '../../data'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 진행 중인 퀘스트 드롭다운
|
* 진행 중인 퀘스트 드롭다운
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import Select from '../../../components/Select'
|
import Select from '../../../../components/common/Select'
|
||||||
import Tooltip from '../../../components/Tooltip'
|
import Tooltip from '../../../../components/common/Tooltip'
|
||||||
import WeeklyScheduler from './WeeklyScheduler'
|
import WeeklyScheduler from './WeeklyScheduler'
|
||||||
import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, calcPoints } from '../data'
|
import { 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 PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
||||||
const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 }
|
const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 }
|
||||||
|
|
@ -61,7 +61,7 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onChange({ done: !sel.done })}
|
onClick={() => onChange({ done: !sel.done })}
|
||||||
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border disabled:cursor-not-allowed"
|
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border"
|
||||||
style={disabled ? {
|
style={disabled ? {
|
||||||
borderColor: 'var(--panel-border)',
|
borderColor: 'var(--panel-border)',
|
||||||
color: 'var(--text-dim)',
|
color: 'var(--text-dim)',
|
||||||
|
|
@ -1,38 +1,10 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import dayjs from 'dayjs'
|
import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES } from '../../data'
|
||||||
import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES, calcPoints } from '../data'
|
import { makeEmptyWeekly } from '../../store'
|
||||||
|
import { bossEarn, calcWeekPoints as calcWeeklySum, getSchedulerWeekRange as getWeekRange } from '../../utils'
|
||||||
import { BossRow } from './WeeklyDefault'
|
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) {
|
function formatRange(r) {
|
||||||
const fmt = (d) => `${d.month() + 1}/${d.date()}`
|
const fmt = (d) => `${d.month() + 1}/${d.date()}`
|
||||||
return `${fmt(r.start)} ~ ${fmt(r.end)}`
|
return `${fmt(r.start)} ~ ${fmt(r.end)}`
|
||||||
|
|
@ -46,17 +18,6 @@ const DIFF_BADGE = {
|
||||||
extreme: { label: 'X', color: '#f59e0b', border: 'rgba(245,158,11,0.5)', bg: 'rgba(245,158,11,0.2)' },
|
extreme: { label: 'X', color: '#f59e0b', border: 'rgba(245,158,11,0.5)', bg: 'rgba(245,158,11,0.2)' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function 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 }) {
|
function BossAvatar({ boss, difficulty, size = 40 }) {
|
||||||
const badge = DIFF_BADGE[difficulty]
|
const badge = DIFF_BADGE[difficulty]
|
||||||
const enabled = difficulty && difficulty !== 'none'
|
const enabled = difficulty && difficulty !== 'none'
|
||||||
|
|
@ -141,7 +102,7 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
|
||||||
export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) {
|
export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) {
|
||||||
const weeks = weeksProp && weeksProp.length > 0
|
const weeks = weeksProp && weeksProp.length > 0
|
||||||
? weeksProp
|
? weeksProp
|
||||||
: [{ id: 1, config: makeEmptyWeek() }]
|
: [{ id: 1, config: makeEmptyWeekly() }]
|
||||||
const setWeeks = (updater) => {
|
const setWeeks = (updater) => {
|
||||||
const next = typeof updater === 'function' ? updater(weeks) : updater
|
const next = typeof updater === 'function' ? updater(weeks) : updater
|
||||||
onChangeWeeks?.(next)
|
onChangeWeeks?.(next)
|
||||||
|
|
@ -153,7 +114,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
||||||
const id = nextId()
|
const id = nextId()
|
||||||
setWeeks((prev) => {
|
setWeeks((prev) => {
|
||||||
const last = prev[prev.length - 1]
|
const last = prev[prev.length - 1]
|
||||||
const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeek()
|
const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeekly()
|
||||||
// done 상태는 복사하지 않음
|
// done 상태는 복사하지 않음
|
||||||
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
|
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
|
||||||
if (base.blackMage) base.blackMage.done = false
|
if (base.blackMage) base.blackMage.done = false
|
||||||
|
|
@ -289,7 +250,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
||||||
onClick={() => removeWeek(w.id)}
|
onClick={() => removeWeek(w.id)}
|
||||||
disabled={weeks.length <= 1}
|
disabled={weeks.length <= 1}
|
||||||
title={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"
|
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 flex items-center justify-center"
|
||||||
style={{ color: 'var(--text-dim)' }}
|
style={{ color: 'var(--text-dim)' }}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
174
frontend/src/features/liberation/utils.js
Normal file
174
frontend/src/features/liberation/utils.js
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { WEEKLY_BOSSES, MONTHLY_BOSSES, calcPoints, todayKST } from './data'
|
||||||
|
import { makeEmptyWeekly } from './store'
|
||||||
|
|
||||||
|
const KST = 'Asia/Seoul'
|
||||||
|
|
||||||
|
export function bossEarn(boss, sel) {
|
||||||
|
if (!sel) return 0
|
||||||
|
const d = boss.difficulties.find((x) => x.key === sel.difficulty)
|
||||||
|
if (!d) return 0
|
||||||
|
return calcPoints(d.points, sel.party)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcWeekPoints(weekData) {
|
||||||
|
let points = 0
|
||||||
|
WEEKLY_BOSSES.forEach((b) => {
|
||||||
|
points += bossEarn(b, weekData.bosses[b.key])
|
||||||
|
})
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcDoneEarn(weekData) {
|
||||||
|
let points = 0
|
||||||
|
WEEKLY_BOSSES.forEach((b) => {
|
||||||
|
const sel = weekData.bosses[b.key]
|
||||||
|
if (sel?.done) points += bossEarn(b, sel)
|
||||||
|
})
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcMonthlyEarn(weekData) {
|
||||||
|
return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주차 번호(1-based)로 해당 주차의 날짜 범위 반환
|
||||||
|
* 1주차: 시작일 ~ 다음 목요일 전날
|
||||||
|
* 2주차+: 이전 주차 목요일부터 6일간
|
||||||
|
*/
|
||||||
|
export function getSchedulerWeekRange(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 ws = nextThu.add((weekIdx - 2) * 7, 'day')
|
||||||
|
return { start: ws, end: ws.add(6, 'day') }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해방일 계산: 시작일부터 포인트 이벤트를 시뮬레이션하여 remaining 도달 시점 반환
|
||||||
|
*
|
||||||
|
* @param {object} params
|
||||||
|
* @param {'simple'|'weekly'} params.calcMode
|
||||||
|
* @param {object} params.state - 현재 슬롯(startDate, schedulerWeeks, weekly 등)
|
||||||
|
* @param {boolean} params.alreadyDone
|
||||||
|
* @param {number} params.remaining
|
||||||
|
* @param {number} params.weeklyEarn
|
||||||
|
* @param {number} params.doneEarn
|
||||||
|
* @param {number} params.monthlyEarn
|
||||||
|
* @param {boolean} params.monthlyDoneThisMonth
|
||||||
|
* @returns {Date|null}
|
||||||
|
*/
|
||||||
|
export function computeCompletionDate({
|
||||||
|
calcMode, state, alreadyDone, remaining,
|
||||||
|
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
|
||||||
|
}) {
|
||||||
|
if (alreadyDone) return todayKST()
|
||||||
|
if (remaining <= 0) return dayjs(state.startDate).tz(KST).startOf('day').toDate()
|
||||||
|
|
||||||
|
const startKST = dayjs(state.startDate).tz(KST).startOf('day')
|
||||||
|
const events = []
|
||||||
|
|
||||||
|
if (calcMode === 'weekly') {
|
||||||
|
const sw = state.schedulerWeeks || []
|
||||||
|
if (sw.length === 0) return null
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검은 마법사: 슬롯 배정에 따라 해당 주차 첫날(or 1주차이면 시작일)에 적립
|
||||||
|
const claimed = {}
|
||||||
|
sw.forEach((w, i) => {
|
||||||
|
const diff = w.config.blackMage?.difficulty
|
||||||
|
if (!diff || diff === 'none') return
|
||||||
|
const range = getSchedulerWeekRange(state.startDate, i + 1)
|
||||||
|
const months = [range.start.format('YYYY-MM'), range.end.format('YYYY-MM')]
|
||||||
|
for (const m of months) {
|
||||||
|
if (!(m in claimed)) {
|
||||||
|
claimed[m] = {
|
||||||
|
weekIdx: i,
|
||||||
|
earn: bossEarn(MONTHLY_BOSSES[0], w.config.blackMage),
|
||||||
|
done: !!w.config.blackMage.done,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Object.entries(claimed).forEach(([, info]) => {
|
||||||
|
if (info.done) return
|
||||||
|
const wIdx = info.weekIdx
|
||||||
|
const date = wIdx === 0
|
||||||
|
? startKST
|
||||||
|
: startKST.add(daysToNextThu + (wIdx - 1) * 7, 'day')
|
||||||
|
events.push({ date, amount: info.earn })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 마지막 주차 이후로는 마지막 주차의 검은 마법사 설정을 매월 반복 적용
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events.sort((a, b) => a.date.diff(b.date))
|
||||||
|
let cumulative = 0
|
||||||
|
for (const e of events) {
|
||||||
|
cumulative += e.amount
|
||||||
|
if (cumulative >= remaining) return e.date.toDate()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* 기능 자동 등록 시스템
|
* 기능 자동 등록 시스템
|
||||||
*
|
*
|
||||||
* - features/{kebab-case}/{PascalCase}.jsx : 사용자 페이지
|
* - features/{kebab-case}/pc/{PascalCase}.jsx : PC 사용자 페이지
|
||||||
* - features/{kebab-case}/{PascalCase}Admin.jsx : 관리자 페이지
|
* - features/{kebab-case}/pc/{PascalCase}Admin.jsx: PC 관리자 페이지
|
||||||
*
|
* - features/{kebab-case}/tablet/{PascalCase}.jsx : 태블릿 사용자 페이지
|
||||||
* 예시:
|
* - features/{kebab-case}/mobile/{PascalCase}.jsx : 모바일 사용자 페이지
|
||||||
* /boss-crystal → features/boss-crystal/BossCrystal.jsx
|
|
||||||
* /admin/boss-crystal → features/boss-crystal/BossCrystalAdmin.jsx
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lazy } from 'react'
|
import { lazy } from 'react'
|
||||||
|
|
||||||
// Vite의 import.meta.glob으로 features 폴더 전체를 스캔
|
const pages = import.meta.glob('./*/{pc,tablet,mobile}/*.jsx')
|
||||||
const userPages = import.meta.glob('./*/*.jsx')
|
|
||||||
|
|
||||||
function slugToPascal(slug) {
|
function slugToPascal(slug) {
|
||||||
return slug
|
return slug
|
||||||
|
|
@ -21,33 +18,47 @@ function slugToPascal(slug) {
|
||||||
.join('')
|
.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컴포넌트 캐시 - 동일 slug에 대해 항상 같은 컴포넌트 인스턴스 반환
|
const userPcCache = new Map()
|
||||||
// (매 렌더마다 새 lazy() 생성하면 React가 unmount/remount하면서 화면 갱신이 깨짐)
|
const adminPcCache = new Map()
|
||||||
const userCache = new Map()
|
const userTabletCache = new Map()
|
||||||
const adminCache = new Map()
|
const userMobileCache = new Map()
|
||||||
|
|
||||||
function loadCached(cache, slug, suffix) {
|
function loadCached(cache, slug, device, suffix) {
|
||||||
if (cache.has(slug)) return cache.get(slug)
|
if (cache.has(slug)) return cache.get(slug)
|
||||||
const pascal = slugToPascal(slug)
|
const pascal = slugToPascal(slug)
|
||||||
const path = `./${slug}/${pascal}${suffix}.jsx`
|
const path = `./${slug}/${device}/${pascal}${suffix}.jsx`
|
||||||
const loader = userPages[path]
|
const loader = pages[path]
|
||||||
const component = loader ? lazy(loader) : null
|
const component = loader ? lazy(loader) : null
|
||||||
cache.set(slug, component)
|
cache.set(slug, component)
|
||||||
return component
|
return component
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* slug에 해당하는 사용자 페이지 컴포넌트 반환
|
* slug에 해당하는 PC 사용자 페이지 컴포넌트 반환
|
||||||
*/
|
*/
|
||||||
export function getUserComponent(slug) {
|
export function getUserComponent(slug) {
|
||||||
return loadCached(userCache, slug, '')
|
return loadCached(userPcCache, slug, 'pc', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* slug에 해당하는 관리자 페이지 컴포넌트 반환
|
* slug에 해당하는 관리자 페이지 컴포넌트 반환 (PC 전용)
|
||||||
*/
|
*/
|
||||||
export function getAdminComponent(slug) {
|
export function getAdminComponent(slug) {
|
||||||
return loadCached(adminCache, slug, 'Admin')
|
return loadCached(adminPcCache, slug, 'pc', 'Admin')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* slug에 해당하는 태블릿 사용자 페이지 컴포넌트 반환
|
||||||
|
*/
|
||||||
|
export function getTabletComponent(slug) {
|
||||||
|
return loadCached(userTabletCache, slug, 'tablet', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* slug에 해당하는 모바일 사용자 페이지 컴포넌트 반환
|
||||||
|
*/
|
||||||
|
export function getMobileComponent(slug) {
|
||||||
|
return loadCached(userMobileCache, slug, 'mobile', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,717 +0,0 @@
|
||||||
import { useState, useEffect, useLayoutEffect, 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 CharacterSuggestDropdown from '../../components/CharacterSuggestDropdown'
|
|
||||||
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()
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
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 [dropdownOpen, setDropdownOpen] = useState(false)
|
|
||||||
|
|
||||||
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('') }}
|
|
||||||
onFocus={() => setDropdownOpen(true)}
|
|
||||||
onBlur={() => setTimeout(() => setDropdownOpen(false), 150)}
|
|
||||||
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)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CharacterSuggestDropdown
|
|
||||||
open={dropdownOpen}
|
|
||||||
filter={addName}
|
|
||||||
excludeNames={characters.map((c) => c.character_name)}
|
|
||||||
onSelect={(n) => {
|
|
||||||
setAddName(n)
|
|
||||||
setDropdownOpen(false)
|
|
||||||
setAddError('')
|
|
||||||
searchMutation.mutate(n)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
106
frontend/src/features/symbol/__tests__/utils.test.js
Normal file
106
frontend/src/features/symbol/__tests__/utils.test.js
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { formatKoreanDate, computeCompletion, TYPE_ORDER, eventBonusForType } from '../utils'
|
||||||
|
|
||||||
|
describe('TYPE_ORDER', () => {
|
||||||
|
it('아케인 → 어센틱 → 그랜드 어센틱 순서', () => {
|
||||||
|
expect(TYPE_ORDER).toEqual(['아케인', '어센틱', '그랜드 어센틱'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatKoreanDate', () => {
|
||||||
|
it('YYYY년 MM월 DD일 (요일) 형식', () => {
|
||||||
|
const d = new Date('2026-04-19T00:00:00+09:00')
|
||||||
|
const s = formatKoreanDate(d)
|
||||||
|
expect(s).toMatch(/^2026년 04월 19일 \([일월화수목금토]\)$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('월/일 2자리 zero-padding', () => {
|
||||||
|
expect(formatKoreanDate(new Date('2026-01-05T00:00:00+09:00')))
|
||||||
|
.toMatch(/^2026년 01월 05일 /)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeCompletion', () => {
|
||||||
|
it('need가 0 (extra로 커버)이면 days 0', () => {
|
||||||
|
const r = computeCompletion({
|
||||||
|
remainingSymbols: 100,
|
||||||
|
daily: 1,
|
||||||
|
weeklyPerWeek: 0,
|
||||||
|
extra: 200,
|
||||||
|
dailyDone: false,
|
||||||
|
})
|
||||||
|
expect(r.days).toBe(0)
|
||||||
|
expect(r.date).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('daily/weekly 모두 0이면 불가능', () => {
|
||||||
|
const r = computeCompletion({
|
||||||
|
remainingSymbols: 100,
|
||||||
|
daily: 0,
|
||||||
|
weeklyPerWeek: 0,
|
||||||
|
extra: 0,
|
||||||
|
dailyDone: false,
|
||||||
|
})
|
||||||
|
expect(r.days).toBeNull()
|
||||||
|
expect(r.date).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('일퀘 하루 1개 · 100개 필요 → 100일 후 완료', () => {
|
||||||
|
const r = computeCompletion({
|
||||||
|
remainingSymbols: 100,
|
||||||
|
daily: 1,
|
||||||
|
weeklyPerWeek: 0,
|
||||||
|
extra: 0,
|
||||||
|
dailyDone: false,
|
||||||
|
})
|
||||||
|
// dailyDone=false라 오늘(day 0)도 적립 → 1/day 누적 100일
|
||||||
|
expect(r.days).toBe(99) // day 0: 1, day 1: 2, ..., day 99: 100 ≥ 100
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dailyDone이면 오늘은 적립 안 됨 → 하루 더 걸림', () => {
|
||||||
|
const r1 = computeCompletion({
|
||||||
|
remainingSymbols: 10, daily: 1, weeklyPerWeek: 0, extra: 0, dailyDone: false,
|
||||||
|
})
|
||||||
|
const r2 = computeCompletion({
|
||||||
|
remainingSymbols: 10, daily: 1, weeklyPerWeek: 0, extra: 0, dailyDone: true,
|
||||||
|
})
|
||||||
|
expect(r2.days).toBe(r1.days + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extra가 remaining을 전부 덮으면 즉시 완료', () => {
|
||||||
|
const r = computeCompletion({
|
||||||
|
remainingSymbols: 50,
|
||||||
|
daily: 5,
|
||||||
|
weeklyPerWeek: 10,
|
||||||
|
extra: 100,
|
||||||
|
dailyDone: false,
|
||||||
|
})
|
||||||
|
expect(r.days).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('eventBonusForType', () => {
|
||||||
|
const skill = { skill_name: '메이플 스위츠', skill_level: 1, arcane_daily: 3, authentic_daily: 7 }
|
||||||
|
|
||||||
|
it('event_skill이 없으면 0', () => {
|
||||||
|
expect(eventBonusForType(null, '아케인')).toBe(0)
|
||||||
|
expect(eventBonusForType(undefined, '어센틱')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('아케인 타입은 arcane_daily 반환', () => {
|
||||||
|
expect(eventBonusForType(skill, '아케인')).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('어센틱/그랜드 어센틱 타입은 authentic_daily 반환', () => {
|
||||||
|
expect(eventBonusForType(skill, '어센틱')).toBe(7)
|
||||||
|
expect(eventBonusForType(skill, '그랜드 어센틱')).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('알 수 없는 타입은 0', () => {
|
||||||
|
expect(eventBonusForType(skill, '기타')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('해당 필드가 누락되어도 0으로 처리', () => {
|
||||||
|
expect(eventBonusForType({ skill_name: 'X', skill_level: 1 }, '아케인')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
354
frontend/src/features/symbol/pc/Symbol.jsx
Normal file
354
frontend/src/features/symbol/pc/Symbol.jsx
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
import { useState, useEffect, useLayoutEffect, useMemo } from 'react'
|
||||||
|
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
||||||
|
import { api } from '../../../api/client'
|
||||||
|
import { useLayout } from '../../../components/pc/Layout'
|
||||||
|
import Tooltip from '../../../components/common/Tooltip'
|
||||||
|
import CharacterSuggestDropdown from '../../../components/common/CharacterSuggestDropdown'
|
||||||
|
import { useSymbolStore } from '../store'
|
||||||
|
import { formatMesoKorean } from '../../../utils/formatting'
|
||||||
|
import { formatKoreanDate, computeCompletion, TYPE_ORDER, eventBonusForType } from '../utils'
|
||||||
|
import CharacterCard from './user/CharacterCard'
|
||||||
|
import SymbolCard from './user/SymbolCard'
|
||||||
|
|
||||||
|
export default function Symbol() {
|
||||||
|
const { setFullscreen } = useLayout()
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
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: useMemo(() => 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,
|
||||||
|
})), [characters]),
|
||||||
|
})
|
||||||
|
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: useMemo(() => characters.map((c) => ({
|
||||||
|
queryKey: ['character', 'symbols', c.id],
|
||||||
|
queryFn: () => api(`/api/character/symbols?ocid=${c.id}`),
|
||||||
|
enabled: !!c.id,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
staleTime: 0,
|
||||||
|
})), [characters]),
|
||||||
|
})
|
||||||
|
|
||||||
|
// symbolQueries 결과를 store로 반영
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allSymbols.length || !characters.length) return
|
||||||
|
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)
|
||||||
|
const nextEs = q.data.event_skill ?? null
|
||||||
|
const prevEs = c.event_skill ?? null
|
||||||
|
if (JSON.stringify(nextEs) !== JSON.stringify(prevEs)) {
|
||||||
|
updateCharacter(c.id, { event_skill: nextEs })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [allSymbols, symbolQueries.map((q) => q.dataUpdatedAt).join(',')])
|
||||||
|
|
||||||
|
const [addName, setAddName] = useState('')
|
||||||
|
const [addError, setAddError] = useState('')
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
|
|
||||||
|
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 selectedChar = characters.find((c) => c.id === selectedCharId)
|
||||||
|
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
|
||||||
|
|
||||||
|
let arrLv = p.level, arrG = p.growth || 0
|
||||||
|
while (arrLv < s.max_level) {
|
||||||
|
const lv2 = s.levels?.find((x) => x.level === arrLv)
|
||||||
|
if (!lv2 || arrG < lv2.required_count) break
|
||||||
|
arr += lv2.meso_cost
|
||||||
|
arrG -= lv2.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 bonus = eventBonusForType(selectedChar?.event_skill, s.type)
|
||||||
|
const dailyValue = p.daily !== undefined ? p.daily : (s.daily_default ?? 0) + bonus
|
||||||
|
const { date } = computeCompletion({
|
||||||
|
remainingSymbols: remaining,
|
||||||
|
daily: dailyValue,
|
||||||
|
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, selectedChar?.event_skill])
|
||||||
|
|
||||||
|
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('') }}
|
||||||
|
onFocus={() => setDropdownOpen(true)}
|
||||||
|
onBlur={() => setTimeout(() => setDropdownOpen(false), 150)}
|
||||||
|
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)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CharacterSuggestDropdown
|
||||||
|
open={dropdownOpen}
|
||||||
|
filter={addName}
|
||||||
|
excludeNames={characters.map((c) => c.character_name)}
|
||||||
|
onSelect={(n) => {
|
||||||
|
setAddName(n)
|
||||||
|
setDropdownOpen(false)
|
||||||
|
setAddError('')
|
||||||
|
searchMutation.mutate(n)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '../../../api/client'
|
import { api } from '../../../../api/client'
|
||||||
import Select from '../../../components/Select'
|
import Select from '../../../../components/common/Select'
|
||||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
|
||||||
import { useAuthStore } from '../../../stores/auth'
|
import FormField, { formInputClass, formInputStyle } from '../../../../components/common/FormField'
|
||||||
|
import { useAuthStore } from '../../../../stores/auth'
|
||||||
|
import { formatMeso } from '../../../../utils/formatting'
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: '아케인', label: '아케인' },
|
{ value: '아케인', label: '아케인' },
|
||||||
|
|
@ -12,27 +14,9 @@ const TYPE_OPTIONS = [
|
||||||
{ value: '그랜드 어센틱', label: '그랜드 어센틱' },
|
{ value: '그랜드 어센틱', label: '그랜드 어센틱' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
|
|
||||||
const inputStyle = {
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
color: 'var(--text-strong)',
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }) {
|
function MesoInput({ value, onChange, ...rest }) {
|
||||||
const display = value === '' || value == null ? '' : Number(String(value).replace(/[^\d]/g, '')).toLocaleString()
|
const display = value === '' || value == null ? '' : Number(String(value).replace(/[^\d]/g, '')).toLocaleString()
|
||||||
const korean = formatMesoKorean(Number(String(value).replace(/[^\d]/g, '')) || 0)
|
const korean = formatMeso(Number(String(value).replace(/[^\d]/g, '')) || 0)
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
|
|
@ -43,35 +27,20 @@ function MesoInput({ value, onChange, ...rest }) {
|
||||||
const digits = e.target.value.replace(/[^\d]/g, '')
|
const digits = e.target.value.replace(/[^\d]/g, '')
|
||||||
onChange(digits)
|
onChange(digits)
|
||||||
}}
|
}}
|
||||||
className={`${inputCls} tabular-nums text-right`}
|
className={`${formInputClass} tabular-nums text-right`}
|
||||||
style={inputStyle}
|
style={formInputStyle}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="text-sm mt-1 text-right tabular-nums min-h-[18px]"
|
className="text-sm mt-1 text-right tabular-nums min-h-[18px]"
|
||||||
style={{ color: 'var(--warning-text-bright)' }}
|
style={{ color: 'var(--warning-text-bright)' }}
|
||||||
>
|
>
|
||||||
{korean || '\u00A0'}
|
{korean === '0' ? '\u00A0' : korean}
|
||||||
</div>
|
</div>
|
||||||
</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" style={{ color: 'var(--text-emphasis)' }}>
|
|
||||||
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
|
|
||||||
</label>
|
|
||||||
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SymbolForm() {
|
export default function SymbolForm() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -216,7 +185,7 @@ export default function SymbolForm() {
|
||||||
<div className="rounded-2xl border p-6 space-y-5" style={panelStyle}>
|
<div className="rounded-2xl border p-6 space-y-5" style={panelStyle}>
|
||||||
<div className="text-sm font-semibold" style={{ color: 'var(--accent-bright)' }}>기본 정보</div>
|
<div className="text-sm font-semibold" style={{ color: 'var(--accent-bright)' }}>기본 정보</div>
|
||||||
|
|
||||||
<Field label="심볼 이미지" required={!isEdit}>
|
<FormField label="심볼 이미지" required={!isEdit}>
|
||||||
<label
|
<label
|
||||||
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
|
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -248,54 +217,54 @@ export default function SymbolForm() {
|
||||||
</div>
|
</div>
|
||||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
|
||||||
</label>
|
</label>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field label="심볼 종류" required>
|
<FormField label="심볼 종류" required>
|
||||||
<Select value={type} onChange={setType} options={TYPE_OPTIONS} />
|
<Select value={type} onChange={setType} options={TYPE_OPTIONS} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="지역 이름" required hint="예: 소멸의 여로">
|
<FormField label="지역 이름" required hint="예: 소멸의 여로">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={region}
|
value={region}
|
||||||
onChange={(e) => setRegion(e.target.value)}
|
onChange={(e) => setRegion(e.target.value)}
|
||||||
className={inputCls}
|
className={formInputClass}
|
||||||
style={inputStyle}
|
style={formInputStyle}
|
||||||
placeholder="소멸의 여로"
|
placeholder="소멸의 여로"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<Field label="만렙" required>
|
<FormField label="만렙" required>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={maxLevel}
|
value={maxLevel}
|
||||||
onChange={(e) => { setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }}
|
onChange={(e) => { setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }}
|
||||||
className={inputCls}
|
className={formInputClass}
|
||||||
style={inputStyle}
|
style={formInputStyle}
|
||||||
min="2"
|
min="2"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="기본 일퀘 획득량">
|
<FormField label="기본 일퀘 획득량">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={dailyDefault}
|
value={dailyDefault}
|
||||||
onChange={(e) => setDailyDefault(e.target.value)}
|
onChange={(e) => setDailyDefault(e.target.value)}
|
||||||
className={inputCls}
|
className={formInputClass}
|
||||||
style={inputStyle}
|
style={formInputStyle}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="기본 주간퀘 획득량">
|
<FormField label="기본 주간퀘 획득량">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={weeklyDefault}
|
value={weeklyDefault}
|
||||||
onChange={(e) => setWeeklyDefault(e.target.value)}
|
onChange={(e) => setWeeklyDefault(e.target.value)}
|
||||||
className={inputCls}
|
className={formInputClass}
|
||||||
style={inputStyle}
|
style={formInputStyle}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -329,8 +298,8 @@ export default function SymbolForm() {
|
||||||
type="number"
|
type="number"
|
||||||
value={l.required_count}
|
value={l.required_count}
|
||||||
onChange={(e) => updateLevel(idx, 'required_count', e.target.value)}
|
onChange={(e) => updateLevel(idx, 'required_count', e.target.value)}
|
||||||
className={`${inputCls} max-w-36`}
|
className={`${formInputClass} max-w-36`}
|
||||||
style={inputStyle}
|
style={formInputStyle}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { api } from '../../../api/client'
|
import { api } from '../../../../api/client'
|
||||||
|
|
||||||
const TYPE_STYLE = {
|
const TYPE_STYLE = {
|
||||||
'아케인': {
|
'아케인': {
|
||||||
57
frontend/src/features/symbol/pc/user/CharacterCard.jsx
Normal file
57
frontend/src/features/symbol/pc/user/CharacterCard.jsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(CharacterCard)
|
||||||
258
frontend/src/features/symbol/pc/user/SymbolCard.jsx
Normal file
258
frontend/src/features/symbol/pc/user/SymbolCard.jsx
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
import { memo, useMemo } from 'react'
|
||||||
|
import Select from '../../../../components/common/Select'
|
||||||
|
import Tooltip from '../../../../components/common/Tooltip'
|
||||||
|
import { useSymbolStore } from '../../store'
|
||||||
|
import { formatMesoKorean } from '../../../../utils/formatting'
|
||||||
|
import { formatKoreanDate, computeCompletion, eventBonusForType } from '../../utils'
|
||||||
|
|
||||||
|
const INPUT_CLASS = "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"
|
||||||
|
const INPUT_STYLE = {
|
||||||
|
background: 'var(--input-bg)',
|
||||||
|
borderColor: 'var(--input-border)',
|
||||||
|
color: 'var(--text-strong)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function SymbolCard({ symbol, equipped, charId }) {
|
||||||
|
const progress = useSymbolStore((s) => s.progress?.[charId]?.[symbol.id])
|
||||||
|
const updateSymbol = useSymbolStore((s) => s.updateSymbol)
|
||||||
|
const eventSkill = useSymbolStore((s) => s.characters.find((c) => c.id === charId)?.event_skill)
|
||||||
|
|
||||||
|
const dailyDone = progress?.dailyDone ?? false
|
||||||
|
const weeklyCount = progress?.weeklyCount ?? 3
|
||||||
|
const baseDefault = symbol.daily_default ?? 0
|
||||||
|
const eventBonus = eventBonusForType(eventSkill, symbol.type)
|
||||||
|
const hasDailyOverride = progress?.daily !== undefined
|
||||||
|
const daily = hasDailyOverride ? progress.daily : baseDefault + eventBonus
|
||||||
|
const extra = progress?.extra ?? 0
|
||||||
|
const patch = (p) => charId && updateSymbol(charId, symbol.id, p)
|
||||||
|
const dailyTooltip = !hasDailyOverride && eventBonus > 0 && eventSkill
|
||||||
|
? `기본 ${baseDefault} + 보약 ${eventBonus} (${eventSkill.skill_name} Lv.${eventSkill.skill_level})`
|
||||||
|
: null
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
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"
|
||||||
|
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={INPUT_CLASS}
|
||||||
|
style={INPUT_STYLE}
|
||||||
|
{...(dailyTooltip ? { title: dailyTooltip } : {})}
|
||||||
|
/>
|
||||||
|
</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={INPUT_CLASS}
|
||||||
|
style={INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
</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) => (
|
||||||
|
<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 memo(SymbolCard)
|
||||||
|
|
@ -3,7 +3,8 @@ import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 심볼 계산기 상태
|
* 심볼 계산기 상태
|
||||||
* characters: [{ id, character_name, character_level, job_name, character_image, ... }]
|
* characters: [{ id, character_name, character_level, job_name, character_image, event_skill, ... }]
|
||||||
|
* - event_skill: { skill_name, skill_level, arcane_daily, authentic_daily } | null
|
||||||
* selectedCharId: 현재 선택된 캐릭터 id (ocid)
|
* selectedCharId: 현재 선택된 캐릭터 id (ocid)
|
||||||
* progress: {
|
* progress: {
|
||||||
* [charId]: {
|
* [charId]: {
|
||||||
|
|
|
||||||
46
frontend/src/features/symbol/utils.js
Normal file
46
frontend/src/features/symbol/utils.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import utc from 'dayjs/plugin/utc'
|
||||||
|
import timezone from 'dayjs/plugin/timezone'
|
||||||
|
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
|
||||||
|
export const KST = 'Asia/Seoul'
|
||||||
|
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
||||||
|
|
||||||
|
export 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이면 오늘 일퀘는 이미 받은 걸로 간주 (내일부터 다시 지급)
|
||||||
|
*/
|
||||||
|
export 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++) {
|
||||||
|
if (!(day === 0 && dailyDone)) acc += daily
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']
|
||||||
|
|
||||||
|
// 이벤트 스킬(보약) 보너스 중 해당 심볼 타입에 적용되는 일퀘 증가량 반환
|
||||||
|
export function eventBonusForType(eventSkill, type) {
|
||||||
|
if (!eventSkill) return 0
|
||||||
|
if (type === '아케인') return eventSkill.arcane_daily || 0
|
||||||
|
if (type === '어센틱' || type === '그랜드 어센틱') return eventSkill.authentic_daily || 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
@ -354,10 +354,6 @@ a {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* number input 화살표 숨기기 */
|
/* number input 화살표 숨기기 */
|
||||||
input[type="number"]::-webkit-inner-spin-button,
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
input[type="number"]::-webkit-outer-spin-button {
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '../api/client'
|
import { api } from '../../api/client'
|
||||||
import NoticeWidget from '../components/NoticeWidget'
|
import NoticeWidget from '../../components/pc/NoticeWidget'
|
||||||
|
import SundayMapleBanner from '../../components/pc/SundayMapleBanner'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { data: menus = [], isLoading: loading } = useQuery({
|
const { data: menus = [], isLoading: loading } = useQuery({
|
||||||
|
|
@ -11,6 +12,9 @@ export default function Home() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10 max-w-5xl mx-auto pt-6">
|
<div className="space-y-10 max-w-5xl mx-auto pt-6">
|
||||||
|
{/* 썬데이 메이플 배너 (금~일만 표시) */}
|
||||||
|
<SundayMapleBanner />
|
||||||
|
|
||||||
{/* 구분선 */}
|
{/* 구분선 */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
0
frontend/src/pages/tablet/.gitkeep
Normal file
0
frontend/src/pages/tablet/.gitkeep
Normal file
26
frontend/src/routes/mobile.jsx
Normal file
26
frontend/src/routes/mobile.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일 라우트 (placeholder)
|
||||||
|
* 추후 MobileLayout, 기능별 모바일 페이지 등록 예정
|
||||||
|
*/
|
||||||
|
export default function MobileRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex items-center justify-center p-6 text-center"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-4xl mb-3 opacity-50">📱</div>
|
||||||
|
<p className="text-sm">모바일 버전은 준비 중입니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
frontend/src/routes/pc.jsx
Normal file
31
frontend/src/routes/pc.jsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import Layout from '../components/pc/Layout'
|
||||||
|
import Home from '../pages/pc/Home'
|
||||||
|
import FeaturePage from '../features/FeaturePage'
|
||||||
|
import AdminLayout from '../features/admin/pc/AdminLayout'
|
||||||
|
import AdminHome from '../features/admin/pc/AdminHome'
|
||||||
|
import AdminImages from '../features/admin/pc/AdminImages'
|
||||||
|
import AdminMenuForm from '../features/admin/pc/AdminMenuForm'
|
||||||
|
import AdminFeaturePage from '../features/admin/pc/AdminFeaturePage'
|
||||||
|
|
||||||
|
export default function PCRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route index element={<Home />} />
|
||||||
|
|
||||||
|
{/* 관리자 */}
|
||||||
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
|
<Route index element={<AdminHome />} />
|
||||||
|
<Route path="images" element={<AdminImages />} />
|
||||||
|
<Route path="menus/new" element={<AdminMenuForm />} />
|
||||||
|
<Route path="menus/:id" element={<AdminMenuForm />} />
|
||||||
|
<Route path=":slug/*" element={<AdminFeaturePage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* 동적 기능 페이지 */}
|
||||||
|
<Route path="/:slug/*" element={<FeaturePage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
frontend/src/routes/tablet.jsx
Normal file
26
frontend/src/routes/tablet.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태블릿 라우트 (placeholder)
|
||||||
|
* 추후 TabletLayout, 기능별 태블릿 페이지 등록 예정
|
||||||
|
*/
|
||||||
|
export default function TabletRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex items-center justify-center p-6 text-center"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-4xl mb-3 opacity-50">📱</div>
|
||||||
|
<p className="text-sm">태블릿 버전은 준비 중입니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
frontend/src/utils/__tests__/formatting.test.js
Normal file
43
frontend/src/utils/__tests__/formatting.test.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { formatMeso, formatMesoKorean } from '../formatting'
|
||||||
|
|
||||||
|
describe('formatMeso', () => {
|
||||||
|
it('0 이하는 "0" 반환', () => {
|
||||||
|
expect(formatMeso(0)).toBe('0')
|
||||||
|
expect(formatMeso(-100)).toBe('0')
|
||||||
|
expect(formatMeso(null)).toBe('0')
|
||||||
|
expect(formatMeso(undefined)).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1만 미만은 그대로 locale 표기', () => {
|
||||||
|
expect(formatMeso(500)).toBe('500')
|
||||||
|
expect(formatMeso(9999)).toBe('9,999')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('만 단위만', () => {
|
||||||
|
expect(formatMeso(10000)).toBe('1만')
|
||||||
|
expect(formatMeso(12345)).toBe('1만')
|
||||||
|
expect(formatMeso(99_990_000)).toBe('9,999만')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('억 단위만', () => {
|
||||||
|
expect(formatMeso(100_000_000)).toBe('1억')
|
||||||
|
expect(formatMeso(500_000_000)).toBe('5억')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('억 + 만 조합', () => {
|
||||||
|
expect(formatMeso(100_010_000)).toBe('1억 1만')
|
||||||
|
expect(formatMeso(123_456_789)).toBe('1억 2,345만')
|
||||||
|
expect(formatMeso(2_576_000_000)).toBe('25억 7,600만')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatMesoKorean은 formatMeso와 동일', () => {
|
||||||
|
expect(formatMesoKorean(100_010_000)).toBe(formatMeso(100_010_000))
|
||||||
|
expect(formatMesoKorean(0)).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('문자열 입력도 처리', () => {
|
||||||
|
expect(formatMeso('12345')).toBe('1만')
|
||||||
|
expect(formatMeso('100000000')).toBe('1억')
|
||||||
|
})
|
||||||
|
})
|
||||||
20
frontend/src/utils/formatting.js
Normal file
20
frontend/src/utils/formatting.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* 메소를 "N억 N,NNN만" 형식의 한국어 문자열로 반환
|
||||||
|
* formatMeso(123456789) → "1억 2,345만"
|
||||||
|
* formatMeso(10000) → "1만"
|
||||||
|
* formatMeso(500) → "500"
|
||||||
|
* formatMeso(0) → "0"
|
||||||
|
*/
|
||||||
|
export function formatMeso(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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 과거 이름 alias (symbol에서 formatMesoKorean으로 쓰던 것)
|
||||||
|
export const formatMesoKorean = formatMeso
|
||||||
Loading…
Add table
Reference in a new issue