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 { Menu } from './Menu.js';
|
||||
import { SundayMaple } from './SundayMaple.js';
|
||||
import { BossCrystalBoss } from './boss-crystal/Boss.js';
|
||||
import { BossCrystalBossDifficulty } from './boss-crystal/BossDifficulty.js';
|
||||
import { Symbol } from './symbol/Symbol.js';
|
||||
|
|
@ -24,4 +25,4 @@ Symbol.hasMany(SymbolLevel, {
|
|||
});
|
||||
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",
|
||||
"axios": "^1.9.0",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.20",
|
||||
"express": "^5.1.0",
|
||||
"mariadb": "^3.4.0",
|
||||
"multer": "^2.0.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"sequelize": "^6.37.5",
|
||||
"sharp": "^0.34.1"
|
||||
}
|
||||
|
|
@ -2314,6 +2316,12 @@
|
|||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -3051,6 +3059,15 @@
|
|||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -8,14 +8,16 @@
|
|||
"dev": "node --watch server.js"
|
||||
},
|
||||
"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",
|
||||
"axios": "^1.9.0",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.20",
|
||||
"express": "^5.1.0",
|
||||
"mariadb": "^3.4.0",
|
||||
"multer": "^2.0.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"sequelize": "^6.37.5",
|
||||
"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) => {
|
||||
const { ocid } = req.query;
|
||||
if (!ocid) return res.status(400).json({ error: 'ocid가 필요합니다' });
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/symbol-equipment`, {
|
||||
params: { ocid },
|
||||
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
|
||||
});
|
||||
const [symbolRes, skillRes] = await Promise.all([
|
||||
axios.get(`${NEXON_API_BASE}/maplestory/v1/character/symbol-equipment`, {
|
||||
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 [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) {
|
||||
const code = err.response?.data?.error?.name;
|
||||
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 imageRoutes from './routes/images.js';
|
||||
import symbolRoutes from './routes/symbol.js';
|
||||
import sundayMapleRoutes from './routes/sunday-maple.js';
|
||||
import { sequelize } from './lib/db.js';
|
||||
import './models/index.js';
|
||||
import { scheduleSundayMapleCron } from './services/sundayMapleCron.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
|
@ -27,6 +29,7 @@ app.use('/api/boss-crystal', bossCrystalRoutes);
|
|||
app.use('/api/character', characterRoutes);
|
||||
app.use('/api/images', imageRoutes);
|
||||
app.use('/api/symbols', symbolRoutes);
|
||||
app.use('/api/sunday-maple', sundayMapleRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
|
|
@ -40,6 +43,8 @@ async function start() {
|
|||
await sequelize.sync();
|
||||
console.log('테이블 동기화 완료');
|
||||
|
||||
scheduleSundayMapleCron();
|
||||
|
||||
app.listen(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",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -19,6 +21,7 @@
|
|||
"overlayscrollbars": "^2.15.1",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"zustand": "^5.0.12"
|
||||
|
|
@ -34,6 +37,7 @@
|
|||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"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 Layout from './components/Layout'
|
||||
import Home from './pages/Home'
|
||||
import FeaturePage from './features/FeaturePage'
|
||||
import AdminLayout from './features/admin/AdminLayout'
|
||||
import AdminHome from './features/admin/AdminHome'
|
||||
import AdminImages from './features/admin/AdminImages'
|
||||
import AdminMenuForm from './features/admin/AdminMenuForm'
|
||||
import AdminFeaturePage from './features/admin/AdminFeaturePage'
|
||||
import { isMobileOnly, isTablet } from 'react-device-detect'
|
||||
import PCRoutes from './routes/pc'
|
||||
import MobileRoutes from './routes/mobile'
|
||||
import TabletRoutes from './routes/tablet'
|
||||
import GlobalTooltip from './components/common/GlobalTooltip'
|
||||
|
||||
function Routes() {
|
||||
if (isMobileOnly) return <MobileRoutes />
|
||||
if (isTablet) return <TabletRoutes />
|
||||
return <PCRoutes />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
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>
|
||||
<>
|
||||
<Routes />
|
||||
<GlobalTooltip />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { useQuery } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { api } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { api } from '../../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
/**
|
||||
* 캐릭터 입력 input 아래 뜨는 드롭다운
|
||||
|
|
@ -15,7 +15,7 @@ export default function Checkbox({ checked, onChange, disabled, className = '',
|
|||
tabIndex={tabIndex}
|
||||
onClick={(e) => { e.stopPropagation(); !disabled && onChange?.(!checked) }}
|
||||
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}`}
|
||||
style={checked ? {
|
||||
borderColor: 'var(--accent)',
|
||||
|
|
@ -135,7 +135,7 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
type="button"
|
||||
onClick={(e) => stop(e, viewMode === 'years' ? prevYearRange : prevMonth)}
|
||||
disabled={viewMode === 'years' ? !canGoPrevYearRange : (year === minYear && month === 0)}
|
||||
className="p-1.5 rounded hover:bg-[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)' }}
|
||||
>
|
||||
<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 { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { api } from '../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export default function LoginDialog({ open, onClose }) {
|
||||
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 { Outlet, Link, useLocation, useMatch } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import { api } from '../../api/client'
|
||||
import Footer from './Footer'
|
||||
import LoginDialog from './LoginDialog'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import LoginDialog from '../common/LoginDialog'
|
||||
import { useThemeStore } from '../../stores/theme'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
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 { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getAdminComponent } from '../registry'
|
||||
import { api } from '../../api/client'
|
||||
import { getAdminComponent } from '../../registry'
|
||||
import { api } from '../../../api/client'
|
||||
|
||||
export default function AdminFeaturePage() {
|
||||
const { slug } = useParams()
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../../api/client'
|
||||
import { api } from '../../../api/client'
|
||||
|
||||
function MenuCard({ menu }) {
|
||||
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 { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { api } from '../../../api/client'
|
||||
import { useAuthStore } from '../../../stores/auth'
|
||||
|
||||
export default function AdminLayout() {
|
||||
const queryClient = useQueryClient()
|
||||
|
|
@ -1,31 +1,10 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../../api/client'
|
||||
import { api } from '../../../api/client'
|
||||
import ImagePicker from './components/ImagePicker'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
|
||||
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)',
|
||||
}
|
||||
import ConfirmDialog from '../../../components/common/ConfirmDialog'
|
||||
import FormField, { formInputClass, formInputStyle } from '../../../components/common/FormField'
|
||||
|
||||
export default function AdminMenuForm() {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -175,29 +154,29 @@ export default function AdminMenuForm() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="제목" required error={errors.title}>
|
||||
<FormField label="제목" required error={errors.title}>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => update({ title: e.target.value })}
|
||||
placeholder="예: 주간 보스 수익 계산기"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
className={formInputClass}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="설명" hint="카드에 표시되는 부가 설명">
|
||||
<FormField label="설명" hint="카드에 표시되는 부가 설명">
|
||||
<input
|
||||
type="text"
|
||||
value={form.description}
|
||||
onChange={(e) => update({ description: e.target.value })}
|
||||
placeholder="예: 캐릭터별 보스 결정석 수익을 계산합니다"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
className={formInputClass}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="경로" required error={errors.slug}>
|
||||
<FormField label="경로" required error={errors.slug}>
|
||||
<div
|
||||
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
||||
style={{
|
||||
|
|
@ -234,9 +213,9 @@ export default function AdminMenuForm() {
|
|||
</code>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="아이콘 이미지" hint="선택사항">
|
||||
<FormField label="아이콘 이미지" hint="선택사항">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -271,7 +250,7 @@ export default function AdminMenuForm() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{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 { useQuery, useQueries } from '@tanstack/react-query'
|
||||
import { api } from '../../api/client'
|
||||
import { useLayout } from '../../components/Layout'
|
||||
import { api } from '../../../api/client'
|
||||
import { useLayout } from '../../../components/pc/Layout'
|
||||
import CharacterPanel from './user/CharacterPanel'
|
||||
import BossSelector from './user/BossSelector'
|
||||
import { useBossStore } from './store'
|
||||
import { useBossStore } from '../store'
|
||||
|
||||
const MAX_PER_CHARACTER = 12
|
||||
|
||||
|
|
@ -1,37 +1,16 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../../../api/client'
|
||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||
import Checkbox from '../../../components/Checkbox'
|
||||
import Select from '../../../components/Select'
|
||||
import { useAuthStore } from '../../../stores/auth'
|
||||
import { api } from '../../../../api/client'
|
||||
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
|
||||
import Checkbox from '../../../../components/common/Checkbox'
|
||||
import Select from '../../../../components/common/Select'
|
||||
import FormField, { formInputClass, formInputStyle } from '../../../../components/common/FormField'
|
||||
import { useAuthStore } from '../../../../stores/auth'
|
||||
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants'
|
||||
|
||||
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() {
|
||||
const obj = {}
|
||||
DIFFICULTIES.forEach((d) => {
|
||||
|
|
@ -197,28 +176,28 @@ export default function BossForm() {
|
|||
>
|
||||
{/* 이름 + 최대 인원 */}
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<Field label="보스 이름" required error={errors.name}>
|
||||
<FormField label="보스 이름" required error={errors.name}>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="예: 검은 마법사"
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
className={formInputClass}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="최대 인원">
|
||||
</FormField>
|
||||
<FormField label="최대 인원">
|
||||
<Select
|
||||
value={maxPartySize}
|
||||
onChange={setMaxPartySize}
|
||||
options={PARTY_OPTIONS}
|
||||
className="w-24"
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* 이미지 */}
|
||||
<Field label="보스 이미지" required={!isEdit} error={errors.image}>
|
||||
<FormField label="보스 이미지" required={!isEdit} error={errors.image}>
|
||||
<label
|
||||
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
|
||||
style={{
|
||||
|
|
@ -256,10 +235,10 @@ export default function BossForm() {
|
|||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
{/* 난이도 */}
|
||||
<Field label="난이도별 결정 정보" required error={errors.difficulties} hint="활성화한 난이도만 저장됩니다">
|
||||
<FormField label="난이도별 결정 정보" required error={errors.difficulties} hint="활성화한 난이도만 저장됩니다">
|
||||
<div className="space-y-2">
|
||||
{DIFFICULTIES.map((d) => {
|
||||
const v = difficulties[d.key]
|
||||
|
|
@ -304,7 +283,7 @@ export default function BossForm() {
|
|||
}}
|
||||
disabled={!v.enabled}
|
||||
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={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: priceErr ? 'var(--icon-danger-border)' : 'var(--input-border)',
|
||||
|
|
@ -326,7 +305,7 @@ export default function BossForm() {
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{isEdit && (
|
||||
|
|
@ -10,8 +10,8 @@ import {
|
|||
arrayMove,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { api } from '../../../api/client'
|
||||
import Tooltip from '../../../components/Tooltip'
|
||||
import { api } from '../../../../api/client'
|
||||
import Tooltip from '../../../../components/common/Tooltip'
|
||||
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants'
|
||||
|
||||
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) {
|
||||
if (!n || n < 10000) return (n || 0).toLocaleString()
|
||||
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()}만`
|
||||
}
|
||||
// formatMeso는 utils/formatting 에서 재-export (모든 기능 공통)
|
||||
export { formatMeso } from '../../../../utils/formatting'
|
||||
|
||||
// difficulty 이미지 URL (S3)
|
||||
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'
|
||||
|
||||
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
|
||||
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={{
|
||||
background: 'var(--surface-2)',
|
||||
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>
|
||||
{/* 목록 (스크롤) */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="divide-y" style={{ '--tw-divide-opacity': 1 }}>
|
||||
<OverlayScrollbarsComponent
|
||||
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) => {
|
||||
const availableDiffs = DIFFICULTIES.filter((d) =>
|
||||
boss.difficulties.some((bd) => bd.difficulty === d.key)
|
||||
|
|
@ -161,7 +169,7 @@ export default function BossSelector({ characterName, bosses, selections, onChan
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,11 +2,11 @@ import { useState } from 'react'
|
|||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Reorder, useDragControls } from 'framer-motion'
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
||||
import { api } from '../../../api/client'
|
||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||
import Tooltip from '../../../components/Tooltip'
|
||||
import CharacterSuggestDropdown from '../../../components/CharacterSuggestDropdown'
|
||||
import { useFitText } from '../../../hooks/useFitText'
|
||||
import { api } from '../../../../api/client'
|
||||
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
|
||||
import Tooltip from '../../../../components/common/Tooltip'
|
||||
import CharacterSuggestDropdown from '../../../../components/common/CharacterSuggestDropdown'
|
||||
import { useFitText } from '../../../../hooks/useFitText'
|
||||
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
|
||||
|
||||
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 = ['일', '월', '화', '수', '목', '금', '토']
|
||||
function formatKoreanDate(s) {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
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 Tooltip from '../../../components/Tooltip'
|
||||
import Select from '../../../../components/common/Select'
|
||||
import Tooltip from '../../../../components/common/Tooltip'
|
||||
import WeeklyScheduler from './WeeklyScheduler'
|
||||
import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, calcPoints } from '../data'
|
||||
import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, calcPoints } from '../../data'
|
||||
|
||||
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
||||
const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 }
|
||||
|
|
@ -61,7 +61,7 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
|
|||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange({ done: !sel.done })}
|
||||
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border disabled:cursor-not-allowed"
|
||||
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border"
|
||||
style={disabled ? {
|
||||
borderColor: 'var(--panel-border)',
|
||||
color: 'var(--text-dim)',
|
||||
|
|
@ -1,38 +1,10 @@
|
|||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import dayjs from 'dayjs'
|
||||
import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES, calcPoints } from '../data'
|
||||
import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES } from '../../data'
|
||||
import { makeEmptyWeekly } from '../../store'
|
||||
import { bossEarn, calcWeekPoints as calcWeeklySum, getSchedulerWeekRange as getWeekRange } from '../../utils'
|
||||
import { BossRow } from './WeeklyDefault'
|
||||
|
||||
function bossEarn(boss, sel) {
|
||||
if (!sel || !sel.difficulty || sel.difficulty === 'none') return 0
|
||||
const d = boss.difficulties.find((x) => x.key === sel.difficulty)
|
||||
if (!d) return 0
|
||||
return calcPoints(d.points, sel.party)
|
||||
}
|
||||
|
||||
function calcWeeklySum(config) {
|
||||
let sum = 0
|
||||
WEEKLY_BOSSES.forEach((b) => { sum += bossEarn(b, config.bosses[b.key]) })
|
||||
return sum
|
||||
}
|
||||
|
||||
const KST = 'Asia/Seoul'
|
||||
|
||||
// 주차별 날짜 범위 계산 (목요일 리셋, 1주차는 시작일부터 다음 목요일 전까지)
|
||||
function getWeekRange(startDateStr, weekIdx) {
|
||||
const start = dayjs(startDateStr).tz(KST).startOf('day')
|
||||
const dow = start.day()
|
||||
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
|
||||
const nextThu = start.add(daysToNextThu, 'day')
|
||||
if (weekIdx === 1) {
|
||||
return { start, end: nextThu.subtract(1, 'day') }
|
||||
}
|
||||
const weekStart = nextThu.add((weekIdx - 2) * 7, 'day')
|
||||
const weekEnd = weekStart.add(6, 'day')
|
||||
return { start: weekStart, end: weekEnd }
|
||||
}
|
||||
|
||||
function formatRange(r) {
|
||||
const fmt = (d) => `${d.month() + 1}/${d.date()}`
|
||||
return `${fmt(r.start)} ~ ${fmt(r.end)}`
|
||||
|
|
@ -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)' },
|
||||
}
|
||||
|
||||
function makeEmptyWeek() {
|
||||
const bosses = {}
|
||||
WEEKLY_BOSSES.forEach((b) => {
|
||||
bosses[b.key] = { difficulty: 'none', party: 1, done: false }
|
||||
})
|
||||
return {
|
||||
bosses,
|
||||
blackMage: { difficulty: 'none', party: 1, done: false },
|
||||
}
|
||||
}
|
||||
|
||||
function BossAvatar({ boss, difficulty, size = 40 }) {
|
||||
const badge = DIFF_BADGE[difficulty]
|
||||
const enabled = difficulty && difficulty !== 'none'
|
||||
|
|
@ -141,7 +102,7 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
|
|||
export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) {
|
||||
const weeks = weeksProp && weeksProp.length > 0
|
||||
? weeksProp
|
||||
: [{ id: 1, config: makeEmptyWeek() }]
|
||||
: [{ id: 1, config: makeEmptyWeekly() }]
|
||||
const setWeeks = (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(weeks) : updater
|
||||
onChangeWeeks?.(next)
|
||||
|
|
@ -153,7 +114,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
|||
const id = nextId()
|
||||
setWeeks((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeek()
|
||||
const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeekly()
|
||||
// done 상태는 복사하지 않음
|
||||
Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
|
||||
if (base.blackMage) base.blackMage.done = false
|
||||
|
|
@ -289,7 +250,7 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
|||
onClick={() => removeWeek(w.id)}
|
||||
disabled={weeks.length <= 1}
|
||||
title={weeks.length <= 1 ? '최소 한 주차는 유지되어야 합니다' : '이 주차 삭제'}
|
||||
className="shrink-0 w-8 h-8 rounded-md hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed flex items-center justify-center"
|
||||
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)' }}
|
||||
>
|
||||
<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}/{PascalCase}Admin.jsx : 관리자 페이지
|
||||
*
|
||||
* 예시:
|
||||
* /boss-crystal → features/boss-crystal/BossCrystal.jsx
|
||||
* /admin/boss-crystal → features/boss-crystal/BossCrystalAdmin.jsx
|
||||
* - features/{kebab-case}/pc/{PascalCase}.jsx : PC 사용자 페이지
|
||||
* - features/{kebab-case}/pc/{PascalCase}Admin.jsx: PC 관리자 페이지
|
||||
* - features/{kebab-case}/tablet/{PascalCase}.jsx : 태블릿 사용자 페이지
|
||||
* - features/{kebab-case}/mobile/{PascalCase}.jsx : 모바일 사용자 페이지
|
||||
*/
|
||||
|
||||
import { lazy } from 'react'
|
||||
|
||||
// Vite의 import.meta.glob으로 features 폴더 전체를 스캔
|
||||
const userPages = import.meta.glob('./*/*.jsx')
|
||||
const pages = import.meta.glob('./*/{pc,tablet,mobile}/*.jsx')
|
||||
|
||||
function slugToPascal(slug) {
|
||||
return slug
|
||||
|
|
@ -21,33 +18,47 @@ function slugToPascal(slug) {
|
|||
.join('')
|
||||
}
|
||||
|
||||
// 컴포넌트 캐시 - 동일 slug에 대해 항상 같은 컴포넌트 인스턴스 반환
|
||||
// (매 렌더마다 새 lazy() 생성하면 React가 unmount/remount하면서 화면 갱신이 깨짐)
|
||||
const userCache = new Map()
|
||||
const adminCache = new Map()
|
||||
const userPcCache = new Map()
|
||||
const adminPcCache = new Map()
|
||||
const userTabletCache = 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)
|
||||
const pascal = slugToPascal(slug)
|
||||
const path = `./${slug}/${pascal}${suffix}.jsx`
|
||||
const loader = userPages[path]
|
||||
const path = `./${slug}/${device}/${pascal}${suffix}.jsx`
|
||||
const loader = pages[path]
|
||||
const component = loader ? lazy(loader) : null
|
||||
cache.set(slug, component)
|
||||
return component
|
||||
}
|
||||
|
||||
/**
|
||||
* slug에 해당하는 사용자 페이지 컴포넌트 반환
|
||||
* slug에 해당하는 PC 사용자 페이지 컴포넌트 반환
|
||||
*/
|
||||
export function getUserComponent(slug) {
|
||||
return loadCached(userCache, slug, '')
|
||||
return loadCached(userPcCache, slug, 'pc', '')
|
||||
}
|
||||
|
||||
/**
|
||||
* slug에 해당하는 관리자 페이지 컴포넌트 반환
|
||||
* slug에 해당하는 관리자 페이지 컴포넌트 반환 (PC 전용)
|
||||
*/
|
||||
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 { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../../../api/client'
|
||||
import Select from '../../../components/Select'
|
||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||
import { useAuthStore } from '../../../stores/auth'
|
||||
import { api } from '../../../../api/client'
|
||||
import Select from '../../../../components/common/Select'
|
||||
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
|
||||
import FormField, { formInputClass, formInputStyle } from '../../../../components/common/FormField'
|
||||
import { useAuthStore } from '../../../../stores/auth'
|
||||
import { formatMeso } from '../../../../utils/formatting'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: '아케인', label: '아케인' },
|
||||
|
|
@ -12,27 +14,9 @@ const TYPE_OPTIONS = [
|
|||
{ 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 }) {
|
||||
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 (
|
||||
<div>
|
||||
<input
|
||||
|
|
@ -43,35 +27,20 @@ function MesoInput({ value, onChange, ...rest }) {
|
|||
const digits = e.target.value.replace(/[^\d]/g, '')
|
||||
onChange(digits)
|
||||
}}
|
||||
className={`${inputCls} tabular-nums text-right`}
|
||||
style={inputStyle}
|
||||
className={`${formInputClass} tabular-nums text-right`}
|
||||
style={formInputStyle}
|
||||
{...rest}
|
||||
/>
|
||||
<div
|
||||
className="text-sm mt-1 text-right tabular-nums min-h-[18px]"
|
||||
style={{ color: 'var(--warning-text-bright)' }}
|
||||
>
|
||||
{korean || '\u00A0'}
|
||||
{korean === '0' ? '\u00A0' : korean}
|
||||
</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() {
|
||||
const navigate = useNavigate()
|
||||
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="text-sm font-semibold" style={{ color: 'var(--accent-bright)' }}>기본 정보</div>
|
||||
|
||||
<Field label="심볼 이미지" required={!isEdit}>
|
||||
<FormField label="심볼 이미지" required={!isEdit}>
|
||||
<label
|
||||
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
|
||||
style={{
|
||||
|
|
@ -248,54 +217,54 @@ export default function SymbolForm() {
|
|||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
|
||||
</label>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="심볼 종류" required>
|
||||
<FormField label="심볼 종류" required>
|
||||
<Select value={type} onChange={setType} options={TYPE_OPTIONS} />
|
||||
</Field>
|
||||
<Field label="지역 이름" required hint="예: 소멸의 여로">
|
||||
</FormField>
|
||||
<FormField label="지역 이름" required hint="예: 소멸의 여로">
|
||||
<input
|
||||
type="text"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
className={formInputClass}
|
||||
style={formInputStyle}
|
||||
placeholder="소멸의 여로"
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label="만렙" required>
|
||||
<FormField label="만렙" required>
|
||||
<input
|
||||
type="number"
|
||||
value={maxLevel}
|
||||
onChange={(e) => { setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }}
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
className={formInputClass}
|
||||
style={formInputStyle}
|
||||
min="2"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="기본 일퀘 획득량">
|
||||
</FormField>
|
||||
<FormField label="기본 일퀘 획득량">
|
||||
<input
|
||||
type="number"
|
||||
value={dailyDefault}
|
||||
onChange={(e) => setDailyDefault(e.target.value)}
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
className={formInputClass}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="기본 주간퀘 획득량">
|
||||
</FormField>
|
||||
<FormField label="기본 주간퀘 획득량">
|
||||
<input
|
||||
type="number"
|
||||
value={weeklyDefault}
|
||||
onChange={(e) => setWeeklyDefault(e.target.value)}
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
className={formInputClass}
|
||||
style={formInputStyle}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -329,8 +298,8 @@ export default function SymbolForm() {
|
|||
type="number"
|
||||
value={l.required_count}
|
||||
onChange={(e) => updateLevel(idx, 'required_count', e.target.value)}
|
||||
className={`${inputCls} max-w-36`}
|
||||
style={inputStyle}
|
||||
className={`${formInputClass} max-w-36`}
|
||||
style={formInputStyle}
|
||||
placeholder="0"
|
||||
/>
|
||||
</td>
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
arrayMove,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { api } from '../../../api/client'
|
||||
import { api } from '../../../../api/client'
|
||||
|
||||
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)
|
||||
* progress: {
|
||||
* [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;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* number input 화살표 숨기기 */
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import NoticeWidget from '../components/NoticeWidget'
|
||||
import { api } from '../../api/client'
|
||||
import NoticeWidget from '../../components/pc/NoticeWidget'
|
||||
import SundayMapleBanner from '../../components/pc/SundayMapleBanner'
|
||||
|
||||
export default function Home() {
|
||||
const { data: menus = [], isLoading: loading } = useQuery({
|
||||
|
|
@ -11,6 +12,9 @@ export default function Home() {
|
|||
|
||||
return (
|
||||
<div className="space-y-10 max-w-5xl mx-auto pt-6">
|
||||
{/* 썬데이 메이플 배너 (금~일만 표시) */}
|
||||
<SundayMapleBanner />
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<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