OAuth 제거, 닉네임 입력 방식으로 변경 및 관리자 페이지 추가
- 넥슨 OAuth 로그인 제거 (Redis, 세션, User 모델 등) - 캐릭터 닉네임 입력 → API 키로 조회하는 방식으로 변경 - 관리자 페이지 추가 (/admin?key=<NEXON_API_KEY>) - 보스 선택 UI를 3단 레이아웃(캐릭터/보스/결과)으로 리디자인 - 캐릭터 및 보스 선택 데이터 localStorage 저장 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4bbb496724
commit
6c51e7d94d
23 changed files with 446 additions and 634 deletions
16
.env
16
.env
|
|
@ -5,10 +5,6 @@ DB_USER=maplestory
|
|||
DB_PASSWORD=xSMK3sG9DG9Vn2dQ
|
||||
DB_NAME=maplestory
|
||||
|
||||
# Redis (세션 저장)
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# RustFS (S3 호환 스토리지)
|
||||
S3_ENDPOINT=http://rustfs:9000
|
||||
S3_PUBLIC_URL=https://s3.caadiq.co.kr
|
||||
|
|
@ -16,16 +12,8 @@ S3_ACCESS_KEY=
|
|||
S3_SECRET_KEY=
|
||||
S3_BUCKET=maplestory
|
||||
|
||||
# 넥슨 OAuth
|
||||
NEXON_CLIENT_ID=
|
||||
NEXON_CLIENT_SECRET=
|
||||
NEXON_REDIRECT_URI=https://maple.caadiq.co.kr/api/auth/callback
|
||||
|
||||
# 넥슨 API (캐릭터 상세 조회용)
|
||||
NEXON_API_KEY=
|
||||
|
||||
# 세션
|
||||
SESSION_SECRET=
|
||||
# 넥슨 API
|
||||
NEXON_API_KEY=test_d32f00908105a5803bf0ce5cf717747c0f06152c00f907ea7f9bb68d3541d2b6efe8d04e6d233bd35cf2fabdeb93fb0d
|
||||
|
||||
# 앱
|
||||
NODE_ENV=development
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import Redis from 'ioredis';
|
||||
|
||||
export const redis = new Redis({
|
||||
host: process.env.REDIS_HOST || 'redis',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||
});
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export function requireAuth(req, res, next) {
|
||||
if (!req.session?.userId) {
|
||||
return res.status(401).json({ error: '로그인이 필요합니다' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import session from 'express-session';
|
||||
import { RedisStore } from 'connect-redis';
|
||||
import { redis } from '../lib/redis.js';
|
||||
|
||||
export const sessionMiddleware = session({
|
||||
store: new RedisStore({ client: redis, prefix: 'maple:sess:' }),
|
||||
secret: process.env.SESSION_SECRET || 'dev-secret',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: true,
|
||||
maxAge: 14 * 24 * 60 * 60 * 1000, // 14일
|
||||
sameSite: 'lax',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../lib/db.js';
|
||||
|
||||
export const User = sequelize.define('User', {
|
||||
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||
nexon_uid: { type: DataTypes.STRING(50), allowNull: false, unique: true },
|
||||
}, {
|
||||
tableName: 'users',
|
||||
underscored: true,
|
||||
});
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../lib/db.js';
|
||||
|
||||
export const UserCharacter = sequelize.define('UserCharacter', {
|
||||
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||
user_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||
character_name: { type: DataTypes.STRING(50), allowNull: false },
|
||||
ocid: { type: DataTypes.STRING(100) },
|
||||
world_name: { type: DataTypes.STRING(20) },
|
||||
job_name: { type: DataTypes.STRING(50) },
|
||||
character_level: { type: DataTypes.INTEGER },
|
||||
character_image: { type: DataTypes.STRING(255) },
|
||||
}, {
|
||||
tableName: 'user_characters',
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{ unique: true, fields: ['user_id', 'character_name'] },
|
||||
],
|
||||
});
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../../lib/db.js';
|
||||
|
||||
export const UserBossSelection = sequelize.define('UserBossSelection', {
|
||||
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
||||
user_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||
user_character_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||
boss_difficulty_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||
party_size: { type: DataTypes.TINYINT, allowNull: false, defaultValue: 1 },
|
||||
}, {
|
||||
tableName: 'user_boss_selections',
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ unique: true, fields: ['user_character_id', 'boss_difficulty_id'] },
|
||||
],
|
||||
});
|
||||
|
|
@ -1,21 +1,8 @@
|
|||
import { User } from './User.js';
|
||||
import { UserCharacter } from './UserCharacter.js';
|
||||
import { Boss } from './boss/Boss.js';
|
||||
import { BossDifficulty } from './boss/BossDifficulty.js';
|
||||
import { UserBossSelection } from './boss/UserBossSelection.js';
|
||||
|
||||
// User <-> UserCharacter
|
||||
User.hasMany(UserCharacter, { foreignKey: 'user_id', as: 'characters' });
|
||||
UserCharacter.belongsTo(User, { foreignKey: 'user_id' });
|
||||
|
||||
// Boss <-> BossDifficulty
|
||||
Boss.hasMany(BossDifficulty, { foreignKey: 'boss_id', as: 'difficulties' });
|
||||
BossDifficulty.belongsTo(Boss, { foreignKey: 'boss_id' });
|
||||
|
||||
// UserBossSelection 관계
|
||||
UserBossSelection.belongsTo(User, { foreignKey: 'user_id' });
|
||||
UserBossSelection.belongsTo(UserCharacter, { foreignKey: 'user_character_id', as: 'character' });
|
||||
UserBossSelection.belongsTo(BossDifficulty, { foreignKey: 'boss_difficulty_id', as: 'difficulty' });
|
||||
UserCharacter.hasMany(UserBossSelection, { foreignKey: 'user_character_id', as: 'selections' });
|
||||
|
||||
export { User, UserCharacter, Boss, BossDifficulty, UserBossSelection };
|
||||
export { Boss, BossDifficulty };
|
||||
|
|
|
|||
195
backend/package-lock.json
generated
195
backend/package-lock.json
generated
|
|
@ -10,12 +10,8 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.800.0",
|
||||
"axios": "^1.9.0",
|
||||
"connect-redis": "^8.0.1",
|
||||
"cors": "^2.8.5",
|
||||
"crypto": "^1.0.1",
|
||||
"express": "^5.1.0",
|
||||
"express-session": "^1.18.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"mariadb": "^3.4.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"sequelize": "^6.37.5"
|
||||
|
|
@ -872,12 +868,6 @@
|
|||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@smithy/chunked-blob-reader": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz",
|
||||
|
|
@ -1740,15 +1730,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
|
|
@ -1761,18 +1742,6 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/connect-redis": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-8.1.0.tgz",
|
||||
"integrity": "sha512-Km0EYLDlmExF52UCss5gLGTtrukGC57G6WCC2aqEMft5Vr4xNWuM4tL+T97kWrw+vp40SXFteb6Xk/7MxgpwdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express-session": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
|
|
@ -1830,13 +1799,6 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
|
||||
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
|
||||
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -2020,50 +1982,6 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "~0.7.2",
|
||||
"cookie-signature": "~1.0.7",
|
||||
"debug": "~2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-headers": "~1.1.0",
|
||||
"parseurl": "~1.3.3",
|
||||
"safe-buffer": "~5.2.1",
|
||||
"uid-safe": "~2.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session/node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express-session/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
|
|
@ -2352,30 +2270,6 @@
|
|||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.10.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
||||
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.5.1",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"denque": "^2.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
|
@ -2403,18 +2297,6 @@
|
|||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
|
|
@ -2616,15 +2498,6 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
|
|
@ -2711,15 +2584,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/random-bytes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
|
|
@ -2744,27 +2608,6 @@
|
|||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/retry-as-promised": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
|
||||
|
|
@ -2787,26 +2630,6 @@
|
|||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
|
|
@ -3034,12 +2857,6 @@
|
|||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
|
@ -3096,18 +2913,6 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/uid-safe": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"random-bytes": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
|
|
|
|||
|
|
@ -13,11 +13,7 @@
|
|||
"sequelize": "^6.37.5",
|
||||
"mysql2": "^3.14.1",
|
||||
"mariadb": "^3.4.0",
|
||||
"express-session": "^1.18.1",
|
||||
"connect-redis": "^8.0.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"@aws-sdk/client-s3": "^3.800.0",
|
||||
"axios": "^1.9.0",
|
||||
"crypto": "^1.0.1"
|
||||
"axios": "^1.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
backend/routes/admin.js
Normal file
28
backend/routes/admin.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 관리자 인증 미들웨어
|
||||
function requireAdmin(req, res, next) {
|
||||
const key = req.headers['x-admin-key'];
|
||||
if (!key || key !== process.env.NEXON_API_KEY) {
|
||||
return res.status(403).json({ error: '접근 권한이 없습니다' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// 관리자 키 검증
|
||||
router.post('/verify', (req, res) => {
|
||||
const { key } = req.body;
|
||||
if (key === process.env.NEXON_API_KEY) {
|
||||
return res.json({ verified: true });
|
||||
}
|
||||
res.status(403).json({ error: '유효하지 않은 키입니다' });
|
||||
});
|
||||
|
||||
// 관리자 API에 미들웨어 적용
|
||||
router.use(requireAdmin);
|
||||
|
||||
// TODO: 보스 관리 API 추가 예정
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { exchangeToken, getUserInfo, refreshToken } from '../services/nexon.js';
|
||||
import { User } from '../models/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/login', (req, res) => {
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
req.session.oauthState = state;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: process.env.NEXON_CLIENT_ID,
|
||||
redirect_uri: process.env.NEXON_REDIRECT_URI,
|
||||
scope: 'maplestory.characterlist',
|
||||
state,
|
||||
});
|
||||
|
||||
res.redirect(`https://openid.nexon.com/oauth2/authorize?${params}`);
|
||||
});
|
||||
|
||||
router.get('/callback', async (req, res) => {
|
||||
const { code, state } = req.query;
|
||||
|
||||
if (!code || state !== req.session.oauthState) {
|
||||
return res.status(400).json({ error: '잘못된 요청입니다' });
|
||||
}
|
||||
delete req.session.oauthState;
|
||||
|
||||
try {
|
||||
const tokens = await exchangeToken(code);
|
||||
const userInfo = await getUserInfo(tokens.access_token);
|
||||
|
||||
const [user] = await User.findOrCreate({
|
||||
where: { nexon_uid: userInfo.uid },
|
||||
});
|
||||
|
||||
req.session.userId = user.id;
|
||||
req.session.accessToken = tokens.access_token;
|
||||
req.session.refreshToken = tokens.refresh_token;
|
||||
req.session.tokenExpiresAt = Date.now() + tokens.expires_in * 1000;
|
||||
|
||||
res.redirect('/');
|
||||
} catch (err) {
|
||||
console.error('OAuth 콜백 오류:', err.message);
|
||||
res.status(500).json({ error: '로그인 처리 중 오류가 발생했습니다' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) return res.status(500).json({ error: '로그아웃 실패' });
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/me', (req, res) => {
|
||||
if (!req.session?.userId) {
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
res.json({ authenticated: true, userId: req.session.userId });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,62 +1,32 @@
|
|||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { getCharacterList, getCharacterOcid, getCharacterBasic } from '../services/nexon.js';
|
||||
import { UserCharacter } from '../models/index.js';
|
||||
import { getCharacterOcid, getCharacterBasic } from '../services/nexon.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 내 캐릭터 목록 조회
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const characters = await UserCharacter.findAll({
|
||||
where: { user_id: req.session.userId },
|
||||
order: [['character_level', 'DESC']],
|
||||
});
|
||||
res.json(characters);
|
||||
} catch (err) {
|
||||
console.error('캐릭터 목록 조회 오류:', err.message);
|
||||
res.status(500).json({ error: '캐릭터 목록 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 캐릭터 목록 갱신 (넥슨 API에서 다시 가져오기)
|
||||
router.post('/refresh', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const charList = await getCharacterList(req.session.accessToken);
|
||||
|
||||
if (!charList?.account_list) {
|
||||
return res.status(400).json({ error: '캐릭터 목록을 가져올 수 없습니다' });
|
||||
// 캐릭터 닉네임으로 정보 조회
|
||||
router.get('/search', async (req, res) => {
|
||||
const { name } = req.query;
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: '캐릭터 닉네임을 입력해주세요' });
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const account of charList.account_list) {
|
||||
for (const char of account.character_list || []) {
|
||||
try {
|
||||
const ocid = await getCharacterOcid(char.character_name);
|
||||
const ocid = await getCharacterOcid(name);
|
||||
const basic = await getCharacterBasic(ocid);
|
||||
|
||||
const [userChar] = await UserCharacter.upsert({
|
||||
user_id: req.session.userId,
|
||||
character_name: char.character_name,
|
||||
ocid,
|
||||
res.json({
|
||||
character_name: basic.character_name,
|
||||
world_name: basic.world_name,
|
||||
job_name: basic.character_class,
|
||||
character_level: basic.character_level,
|
||||
character_image: basic.character_image,
|
||||
});
|
||||
|
||||
results.push(userChar);
|
||||
} catch (err) {
|
||||
console.error(`캐릭터 조회 실패: ${char.character_name}`, err.message);
|
||||
if (err.response?.status === 400) {
|
||||
return res.status(404).json({ error: '존재하지 않는 캐릭터입니다' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
} catch (err) {
|
||||
console.error('캐릭터 갱신 오류:', err.message);
|
||||
res.status(500).json({ error: '캐릭터 목록 갱신 실패' });
|
||||
console.error('캐릭터 조회 오류:', err.message);
|
||||
res.status(500).json({ error: '캐릭터 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { sessionMiddleware } from './middleware/session.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import characterRoutes from './routes/characters.js';
|
||||
import bossRoutes from './routes/boss/bosses.js';
|
||||
import selectionRoutes from './routes/boss/selections.js';
|
||||
import calculateRoutes from './routes/boss/calculate.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
import { sequelize } from './lib/db.js';
|
||||
|
||||
const app = express();
|
||||
|
|
@ -18,13 +15,10 @@ app.use(cors({
|
|||
credentials: true,
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/characters', characterRoutes);
|
||||
app.use('/api/boss', bossRoutes);
|
||||
app.use('/api/boss/selections', selectionRoutes);
|
||||
app.use('/api/boss/calculate', calculateRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
|
|
|
|||
|
|
@ -1,49 +1,6 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const NEXON_API_BASE = 'https://open.api.nexon.com';
|
||||
const NEXON_OPENID_BASE = 'https://openid.nexon.com';
|
||||
|
||||
export async function exchangeToken(code) {
|
||||
const { data } = await axios.post(
|
||||
`${NEXON_OPENID_BASE}/oauth2/token`,
|
||||
new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: process.env.NEXON_CLIENT_ID,
|
||||
client_secret: process.env.NEXON_CLIENT_SECRET,
|
||||
code,
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshToken(refreshToken) {
|
||||
const { data } = await axios.post(
|
||||
`${NEXON_OPENID_BASE}/oauth2/token`,
|
||||
new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: process.env.NEXON_CLIENT_ID,
|
||||
client_secret: process.env.NEXON_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserInfo(accessToken) {
|
||||
const { data } = await axios.get(`${NEXON_OPENID_BASE}/api/v1/user/info`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
return data.result;
|
||||
}
|
||||
|
||||
export async function getCharacterList(accessToken) {
|
||||
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/list`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getCharacterOcid(characterName) {
|
||||
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/id`, {
|
||||
|
|
|
|||
|
|
@ -28,17 +28,9 @@ services:
|
|||
- db
|
||||
- app
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- app
|
||||
|
||||
volumes:
|
||||
frontend_modules:
|
||||
backend_modules:
|
||||
redis-data:
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom'
|
|||
import Layout from './components/Layout'
|
||||
import Home from './pages/Home'
|
||||
import BossPage from './features/boss/BossPage'
|
||||
import Admin from './pages/Admin'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
|
@ -9,6 +10,7 @@ export default function App() {
|
|||
<Route element={<Layout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="/boss" element={<BossPage />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Outlet, Link } from 'react-router-dom'
|
||||
import LoginButton from './LoginButton'
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
|
|
@ -9,7 +8,6 @@ export default function Layout() {
|
|||
<Link to="/" className="text-xl font-bold">메이플스토리 도우미</Link>
|
||||
<nav className="flex items-center gap-6">
|
||||
<Link to="/boss" className="text-gray-400 hover:text-white transition">보스 계산기</Link>
|
||||
<LoginButton />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export default function LoginButton() {
|
||||
const { authenticated, loading, logout } = useAuth()
|
||||
|
||||
if (loading) return null
|
||||
|
||||
if (authenticated) {
|
||||
return (
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded bg-gray-700 px-4 py-2 text-sm hover:bg-gray-600 transition"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href="/api/auth/login"
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm hover:bg-blue-500 transition"
|
||||
>
|
||||
넥슨 로그인
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,51 +1,59 @@
|
|||
import { useState } from 'react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
const DIFF_KEYS = { '이지': 'easy', '노말': 'normal', '하드': 'hard', '카오스': 'chaos', '익스트림': 'extreme' }
|
||||
const DIFF_COLORS = {
|
||||
'이지': 'text-green-400 border-green-400/30 bg-green-400/10',
|
||||
'노말': 'text-gray-300 border-gray-500/30 bg-gray-500/10',
|
||||
'하드': 'text-rose-400 border-rose-400/30 bg-rose-400/10',
|
||||
'카오스': 'text-amber-400 border-amber-400/30 bg-amber-400/10',
|
||||
'익스트림': 'text-red-500 border-red-500/30 bg-red-500/10',
|
||||
}
|
||||
|
||||
const DUMMY_BOSSES = [
|
||||
{
|
||||
id: 1, name: '자쿰', imgId: 1,
|
||||
difficulties: [
|
||||
{ name: '이지', crystal: 6_612_500, defaultParty: 1 },
|
||||
{ name: '노말', crystal: 16_200_000, defaultParty: 1 },
|
||||
{ name: '카오스', crystal: 81_000_000, defaultParty: 1 },
|
||||
{ name: '이지', crystal: 6_612_500, maxParty: 1 },
|
||||
{ name: '노말', crystal: 16_200_000, maxParty: 1 },
|
||||
{ name: '카오스', crystal: 81_000_000, maxParty: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2, name: '힐라', imgId: 3,
|
||||
difficulties: [
|
||||
{ name: '노말', crystal: 6_612_500, defaultParty: 1 },
|
||||
{ name: '하드', crystal: 56_250_000, defaultParty: 1 },
|
||||
{ name: '노말', crystal: 6_612_500, maxParty: 1 },
|
||||
{ name: '하드', crystal: 56_250_000, maxParty: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3, name: '매그너스', imgId: 10,
|
||||
difficulties: [
|
||||
{ name: '이지', crystal: 7_200_000, defaultParty: 1 },
|
||||
{ name: '노말', crystal: 19_012_500, defaultParty: 1 },
|
||||
{ name: '하드', crystal: 95_062_500, defaultParty: 1 },
|
||||
{ name: '이지', crystal: 7_200_000, maxParty: 1 },
|
||||
{ name: '노말', crystal: 19_012_500, maxParty: 1 },
|
||||
{ name: '하드', crystal: 95_062_500, maxParty: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4, name: '파풀라투스', imgId: 22,
|
||||
difficulties: [
|
||||
{ name: '이지', crystal: 4_012_500, defaultParty: 1 },
|
||||
{ name: '노말', crystal: 13_012_500, defaultParty: 1 },
|
||||
{ name: '카오스', crystal: 79_012_500, defaultParty: 1 },
|
||||
{ name: '이지', crystal: 4_012_500, maxParty: 1 },
|
||||
{ name: '노말', crystal: 13_012_500, maxParty: 1 },
|
||||
{ name: '카오스', crystal: 79_012_500, maxParty: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5, name: '듄켈', imgId: 27,
|
||||
difficulties: [
|
||||
{ name: '노말', crystal: 92_450_000, defaultParty: 1 },
|
||||
{ name: '하드', crystal: 231_125_000, defaultParty: 6 },
|
||||
{ name: '노말', crystal: 92_450_000, maxParty: 1 },
|
||||
{ name: '하드', crystal: 231_125_000, maxParty: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 6, name: '림보', imgId: 33,
|
||||
difficulties: [
|
||||
{ name: '노말', crystal: 140_000_000, defaultParty: 1 },
|
||||
{ name: '하드', crystal: 350_000_000, defaultParty: 6 },
|
||||
{ name: '노말', crystal: 140_000_000, maxParty: 1 },
|
||||
{ name: '하드', crystal: 350_000_000, maxParty: 6 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -60,69 +68,324 @@ function formatMeso(n) {
|
|||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
function BossRowList({ boss, selections, onChange }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="flex items-center gap-3 bg-gray-900 px-3 py-2">
|
||||
<img src={`/boss-images/icon/${boss.imgId}.png`} alt={boss.name} className="w-10 h-10 rounded object-cover" />
|
||||
<span className="font-medium">{boss.name}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-800/50">
|
||||
{boss.difficulties.map((diff, i) => {
|
||||
const key = `${boss.id}-${i}`
|
||||
const sel = selections[key] || { enabled: false, party: diff.defaultParty }
|
||||
return (
|
||||
<label key={i} className={`flex items-center gap-3 px-3 py-2 cursor-pointer transition ${sel.enabled ? '' : 'opacity-50'}`}>
|
||||
<input type="checkbox" checked={sel.enabled} onChange={(e) => onChange(key, { ...sel, enabled: e.target.checked })} className="accent-emerald-500 w-4 h-4 shrink-0" />
|
||||
<img src={`/boss-images/diff-badge/${DIFF_KEYS[diff.name]}.png`} alt={diff.name} className="h-5 shrink-0" />
|
||||
<div className="flex-1 text-sm text-gray-400">{formatMeso(diff.crystal)}</div>
|
||||
<select value={sel.party} onChange={(e) => { e.stopPropagation(); onChange(key, { ...sel, party: Number(e.target.value) }) }} className="bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-xs text-gray-300 outline-none w-14 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{[1, 2, 3, 4, 5, 6].map((n) => <option key={n} value={n}>÷{n}인</option>)}
|
||||
</select>
|
||||
<div className={`text-sm font-medium w-20 text-right shrink-0 ${sel.enabled ? 'text-green-400' : 'text-gray-600'}`}>
|
||||
{sel.enabled ? formatMeso(Math.floor(diff.crystal / sel.party)) : '-'}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
/* ── 좌측: 캐릭터 패널 ── */
|
||||
function CharacterPanel({ characters, selectedChar, onSelect, onAdd, onRemove }) {
|
||||
const [name, setName] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api(`/api/characters/search?name=${encodeURIComponent(name.trim())}`)
|
||||
onAdd(data)
|
||||
setName('')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
export default function BossPage() {
|
||||
const [selections, setSelections] = useState({})
|
||||
|
||||
const handleChange = (key, sel) => setSelections((prev) => ({ ...prev, [key]: sel }))
|
||||
|
||||
const entries = Object.entries(selections).filter(([, s]) => s.enabled)
|
||||
const totalCrystals = entries.length
|
||||
const totalRevenue = entries.reduce((sum, [key, sel]) => {
|
||||
const [bossId, diffIdx] = key.split('-').map(Number)
|
||||
const boss = DUMMY_BOSSES.find((b) => b.id === bossId)
|
||||
return sum + Math.floor(boss.difficulties[diffIdx].crystal / sel.party)
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">주간 보스 수익 계산기</h1>
|
||||
|
||||
<div className="flex gap-6 rounded-lg border border-gray-800 bg-gray-900/50 p-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">결정석</div>
|
||||
<div className="text-lg font-bold">{totalCrystals}<span className="text-gray-500 text-sm">/12</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">예상 수익</div>
|
||||
<div className="text-lg font-bold text-green-400">{formatMeso(totalRevenue)} <span className="text-sm text-gray-400">메소</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{DUMMY_BOSSES.map((boss) => (
|
||||
<BossRowList key={boss.id} boss={boss} selections={selections} onChange={handleChange} />
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">1. 캐릭터 등록</h2>
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="닉네임 입력"
|
||||
className="flex-1 min-w-0 rounded border border-gray-700 bg-gray-900 px-3 py-1.5 text-sm outline-none focus:border-emerald-500 transition"
|
||||
/>
|
||||
<button type="submit" disabled={loading} className="rounded bg-emerald-600 px-3 py-1.5 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50 transition shrink-0">
|
||||
{loading ? '...' : '등록'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
<div className="space-y-1">
|
||||
{characters.map((char) => (
|
||||
<div
|
||||
key={char.character_name}
|
||||
onClick={() => onSelect(char.character_name)}
|
||||
className={`flex items-center gap-2 rounded-lg px-2 py-2 cursor-pointer transition group ${
|
||||
selectedChar === char.character_name
|
||||
? 'bg-emerald-500/10 border border-emerald-500/50'
|
||||
: 'hover:bg-gray-800/50 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{char.character_image && (
|
||||
<img src={char.character_image} alt="" className="w-10 h-10 rounded bg-gray-800" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{char.character_name}</div>
|
||||
<div className="text-xs text-gray-500">Lv.{char.character_level} {char.job_name}</div>
|
||||
</div>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(char.character_name) }}
|
||||
className="text-gray-700 hover:text-red-400 opacity-0 group-hover:opacity-100 transition cursor-pointer text-lg"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 중앙: 보스 선택 패널 ── */
|
||||
function BossPanel({ selectedChar, selections, onChange }) {
|
||||
if (!selectedChar) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
|
||||
캐릭터를 선택해주세요
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">2. 보스 선택</h2>
|
||||
|
||||
<div className="rounded-lg border border-gray-800 overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="grid grid-cols-[2fr_1fr_1fr_1fr] gap-2 px-3 py-2 bg-gray-900/80 text-xs text-gray-500 border-b border-gray-800">
|
||||
<div>보스</div>
|
||||
<div>난이도</div>
|
||||
<div>파티원 수</div>
|
||||
<div className="text-right">수익</div>
|
||||
</div>
|
||||
|
||||
{/* 보스 행 */}
|
||||
<div className="divide-y divide-gray-800/50">
|
||||
{DUMMY_BOSSES.map((boss) => {
|
||||
// 현재 캐릭터에서 이 보스의 선택된 난이도 찾기
|
||||
const selectedDiffIdx = boss.difficulties.findIndex((_, i) => {
|
||||
const key = `${boss.id}-${i}`
|
||||
return selections[key]?.enabled
|
||||
})
|
||||
const sel = selectedDiffIdx >= 0 ? selections[`${boss.id}-${selectedDiffIdx}`] : null
|
||||
const diff = selectedDiffIdx >= 0 ? boss.difficulties[selectedDiffIdx] : null
|
||||
const isSelected = !!sel?.enabled
|
||||
|
||||
return (
|
||||
<div key={boss.id} className={`grid grid-cols-[2fr_1fr_1fr_1fr] gap-2 px-3 py-2 items-center transition ${isSelected ? '' : 'opacity-40'}`}>
|
||||
{/* 보스 이름 + 아이콘 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={`/boss-images/icon/${boss.imgId}.png`} alt={boss.name} className="w-8 h-8 rounded object-cover shrink-0" />
|
||||
<span className="text-sm font-medium truncate">{boss.name}</span>
|
||||
</div>
|
||||
|
||||
{/* 난이도 선택 */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{boss.difficulties.map((d, i) => {
|
||||
const key = `${boss.id}-${i}`
|
||||
const active = selections[key]?.enabled
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
// 라디오 방식: 같은 보스에서 하나만 선택
|
||||
const newSelections = { ...selections }
|
||||
boss.difficulties.forEach((_, j) => {
|
||||
const k = `${boss.id}-${j}`
|
||||
if (j === i) {
|
||||
newSelections[k] = { enabled: !active, party: active ? d.maxParty : (selections[k]?.party || d.maxParty) }
|
||||
} else {
|
||||
newSelections[k] = { ...newSelections[k], enabled: false }
|
||||
}
|
||||
})
|
||||
onChange(newSelections)
|
||||
}}
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition ${
|
||||
active ? DIFF_COLORS[d.name] : 'text-gray-600 border-gray-700/50 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{d.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 파티원 수 */}
|
||||
<div>
|
||||
{isSelected && (
|
||||
<select
|
||||
value={sel.party}
|
||||
onChange={(e) => {
|
||||
const key = `${boss.id}-${selectedDiffIdx}`
|
||||
onChange({ ...selections, [key]: { ...sel, party: Number(e.target.value) } })
|
||||
}}
|
||||
className="bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-xs text-gray-300 outline-none"
|
||||
>
|
||||
{Array.from({ length: diff.maxParty }, (_, i) => i + 1).map((n) => (
|
||||
<option key={n} value={n}>{n}인</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 수익 */}
|
||||
<div className={`text-right text-sm font-medium ${isSelected ? 'text-green-400' : ''}`}>
|
||||
{isSelected ? formatMeso(Math.floor(diff.crystal / sel.party)) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 우측: 결과 패널 ── */
|
||||
function ResultPanel({ characters, allSelections }) {
|
||||
let totalCrystals = 0
|
||||
let totalRevenue = 0
|
||||
|
||||
const charResults = characters.map((char) => {
|
||||
const charSel = allSelections[char.character_name] || {}
|
||||
let crystals = 0
|
||||
let revenue = 0
|
||||
|
||||
Object.entries(charSel).forEach(([key, sel]) => {
|
||||
if (!sel.enabled) return
|
||||
const [bossId, diffIdx] = key.split('-').map(Number)
|
||||
const boss = DUMMY_BOSSES.find((b) => b.id === bossId)
|
||||
if (!boss) return
|
||||
crystals++
|
||||
revenue += Math.floor(boss.difficulties[diffIdx].crystal / sel.party)
|
||||
})
|
||||
|
||||
totalCrystals += crystals
|
||||
totalRevenue += revenue
|
||||
|
||||
return { name: char.character_name, crystals, revenue }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">3. 결과</h2>
|
||||
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-900/50 p-4 space-y-4">
|
||||
{/* 합산 */}
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<span className="text-sm text-gray-400">보유 결정석</span>
|
||||
<div className="text-2xl font-bold">{totalCrystals}<span className="text-gray-500 text-base">/90</span></div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-sm text-gray-400">총 수익</span>
|
||||
<div className="text-2xl font-bold text-green-400">{formatMeso(totalRevenue)}</div>
|
||||
<div className="text-xs text-gray-500">메소</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결정석 게이지 */}
|
||||
<div className="w-full bg-gray-800 rounded-full h-2">
|
||||
<div
|
||||
className="bg-emerald-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((totalCrystals / 90) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 캐릭터별 소계 */}
|
||||
{charResults.length > 0 && (
|
||||
<div className="space-y-1 pt-2 border-t border-gray-800">
|
||||
<div className="text-xs text-gray-500 mb-2">캐릭터별</div>
|
||||
{charResults.map((r) => (
|
||||
<div key={r.name} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">{r.name}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-500 text-xs">{r.crystals}/12</span>
|
||||
<span className={r.revenue > 0 ? 'text-green-400' : 'text-gray-600'}>{r.revenue > 0 ? formatMeso(r.revenue) : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 메인 ── */
|
||||
export default function BossPage() {
|
||||
const [characters, setCharacters] = useState(() => {
|
||||
const saved = localStorage.getItem('maple-characters')
|
||||
return saved ? JSON.parse(saved) : []
|
||||
})
|
||||
const [selectedChar, setSelectedChar] = useState(null)
|
||||
const [allSelections, setAllSelections] = useState(() => {
|
||||
const saved = localStorage.getItem('maple-boss-selections')
|
||||
return saved ? JSON.parse(saved) : {}
|
||||
})
|
||||
|
||||
const saveCharacters = (chars) => {
|
||||
setCharacters(chars)
|
||||
localStorage.setItem('maple-characters', JSON.stringify(chars))
|
||||
}
|
||||
|
||||
const saveSelections = (sels) => {
|
||||
setAllSelections(sels)
|
||||
localStorage.setItem('maple-boss-selections', JSON.stringify(sels))
|
||||
}
|
||||
|
||||
const handleAddCharacter = (charData) => {
|
||||
if (characters.find((c) => c.character_name === charData.character_name)) return
|
||||
saveCharacters([...characters, charData])
|
||||
setSelectedChar(charData.character_name)
|
||||
}
|
||||
|
||||
const handleRemoveCharacter = (name) => {
|
||||
saveCharacters(characters.filter((c) => c.character_name !== name))
|
||||
if (selectedChar === name) setSelectedChar(null)
|
||||
const newSelections = { ...allSelections }
|
||||
delete newSelections[name]
|
||||
saveSelections(newSelections)
|
||||
}
|
||||
|
||||
const handleBossChange = (charSelections) => {
|
||||
if (!selectedChar) return
|
||||
saveSelections({ ...allSelections, [selectedChar]: charSelections })
|
||||
}
|
||||
|
||||
const currentSelections = selectedChar ? (allSelections[selectedChar] || {}) : {}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 lg:space-y-0 lg:grid lg:grid-cols-[240px_1fr_280px] lg:gap-6">
|
||||
{/* 좌측 */}
|
||||
<div className="lg:border-r lg:border-gray-800 lg:pr-6">
|
||||
<CharacterPanel
|
||||
characters={characters}
|
||||
selectedChar={selectedChar}
|
||||
onSelect={setSelectedChar}
|
||||
onAdd={handleAddCharacter}
|
||||
onRemove={handleRemoveCharacter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 중앙 */}
|
||||
<div className="min-w-0">
|
||||
<BossPanel
|
||||
selectedChar={selectedChar}
|
||||
selections={currentSelections}
|
||||
onChange={handleBossChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 우측 */}
|
||||
<div className="lg:border-l lg:border-gray-800 lg:pl-6">
|
||||
<ResultPanel
|
||||
characters={characters}
|
||||
allSelections={allSelections}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export function useAuth() {
|
||||
const [authenticated, setAuthenticated] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api('/api/auth/me')
|
||||
.then((data) => setAuthenticated(data.authenticated))
|
||||
.catch(() => setAuthenticated(false))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const logout = async () => {
|
||||
await api('/api/auth/logout', { method: 'POST' })
|
||||
setAuthenticated(false)
|
||||
}
|
||||
|
||||
return { authenticated, loading, logout }
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export function useCharacters() {
|
||||
const [characters, setCharacters] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchCharacters = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('/api/characters')
|
||||
setCharacters(data)
|
||||
} catch {
|
||||
setCharacters([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshCharacters = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('/api/characters/refresh', { method: 'POST' })
|
||||
setCharacters(data)
|
||||
} catch {
|
||||
// 무시
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchCharacters() }, [fetchCharacters])
|
||||
|
||||
return { characters, loading, refreshCharacters }
|
||||
}
|
||||
55
frontend/src/pages/Admin.jsx
Normal file
55
frontend/src/pages/Admin.jsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams, Navigate } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export default function Admin() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [verified, setVerified] = useState(null) // null=로딩, true=인증됨, false=실패
|
||||
|
||||
useEffect(() => {
|
||||
const keyFromUrl = searchParams.get('key')
|
||||
const keyFromStorage = localStorage.getItem('maple-admin-key')
|
||||
const key = keyFromUrl || keyFromStorage
|
||||
|
||||
if (!key) {
|
||||
setVerified(false)
|
||||
return
|
||||
}
|
||||
|
||||
api('/api/admin/verify', { method: 'POST', body: { key } })
|
||||
.then(() => {
|
||||
localStorage.setItem('maple-admin-key', key)
|
||||
setVerified(true)
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('maple-admin-key')
|
||||
setVerified(false)
|
||||
})
|
||||
}, [searchParams])
|
||||
|
||||
if (verified === null) {
|
||||
return <div className="text-center text-gray-400 pt-16">인증 중...</div>
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">관리자</h1>
|
||||
<button
|
||||
onClick={() => { localStorage.removeItem('maple-admin-key'); setVerified(false) }}
|
||||
className="text-sm text-gray-500 hover:text-gray-300 transition"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-900/50 p-8 text-center text-gray-500">
|
||||
관리자 페이지 준비 중
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue