이미지 다중 업로드/삭제 및 중복 방지 추가

- 한 번에 여러 이미지 업로드 (드래그/선택, 개별 이름 수정/제거)
- 다중 선택 삭제 모드 (선택 모드 토글, 전체 선택)
- 커스텀 확인 다이얼로그 (네이티브 confirm 대체)
- 이미지 이름 unique 제약 + 입력 시 실시간 중복/빈 값 검증
- 백엔드 다중 업로드 시 사전 중복 체크
- 카드에서 URL 표시 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-13 14:42:51 +09:00
parent 65fdc70ff2
commit 921ce9676b
18 changed files with 1224 additions and 404 deletions

4
.env
View file

@ -8,8 +8,8 @@ DB_NAME=maplestory
# RustFS (S3 호환 스토리지)
S3_ENDPOINT=http://rustfs:9000
S3_PUBLIC_URL=https://s3.caadiq.co.kr
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_ACCESS_KEY=IN7FjBydNQbnUybLLVfk
S3_SECRET_KEY=u1m508WWLGQsn5ueRXV4qPID8OVqiz0Pnm9QDVeI
S3_BUCKET=maplestory
# 넥슨 API

34
backend/lib/s3.js Normal file
View file

@ -0,0 +1,34 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
export const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: 'auto',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
forcePathStyle: true,
});
export const S3_BUCKET = process.env.S3_BUCKET;
export const S3_PUBLIC_URL = process.env.S3_PUBLIC_URL;
export async function uploadObject(key, body, contentType) {
await s3.send(new PutObjectCommand({
Bucket: S3_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
}));
}
export async function deleteObject(key) {
await s3.send(new DeleteObjectCommand({
Bucket: S3_BUCKET,
Key: key,
}));
}
export function getPublicUrl(key) {
return `${S3_PUBLIC_URL}/${S3_BUCKET}/${key}`;
}

14
backend/models/Image.js Normal file
View file

@ -0,0 +1,14 @@
import { DataTypes } from 'sequelize';
import { sequelize } from '../lib/db.js';
export const Image = sequelize.define('Image', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
name: { type: DataTypes.STRING(100), allowNull: false, unique: true },
path: { type: DataTypes.STRING(255), allowNull: false }, // S3 키 (예: common/abc.webp)
width: { type: DataTypes.INTEGER },
height: { type: DataTypes.INTEGER },
size: { type: DataTypes.INTEGER }, // bytes
}, {
tableName: 'images',
underscored: true,
});

View file

@ -1,12 +0,0 @@
import { DataTypes } from 'sequelize';
import { sequelize } from '../../lib/db.js';
export const Boss = sequelize.define('Boss', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
name: { type: DataTypes.STRING(50), allowNull: false },
sort_order: { type: DataTypes.INTEGER, defaultValue: 0 },
image_url: { type: DataTypes.STRING(255) },
}, {
tableName: 'bosses',
underscored: true,
});

View file

@ -1,18 +0,0 @@
import { DataTypes } from 'sequelize';
import { sequelize } from '../../lib/db.js';
export const BossDifficulty = sequelize.define('BossDifficulty', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
boss_id: { type: DataTypes.INTEGER, allowNull: false },
difficulty: { type: DataTypes.ENUM('easy', 'normal', 'hard', 'chaos', 'extreme'), allowNull: false },
crystal_price: { type: DataTypes.BIGINT, allowNull: false },
required_level: { type: DataTypes.INTEGER, defaultValue: 0 },
max_party_size: { type: DataTypes.TINYINT, defaultValue: 1 },
}, {
tableName: 'boss_difficulties',
underscored: true,
timestamps: false,
indexes: [
{ unique: true, fields: ['boss_id', 'difficulty'] },
],
});

View file

@ -1,8 +1,3 @@
import { Boss } from './boss/Boss.js';
import { BossDifficulty } from './boss/BossDifficulty.js';
import { Image } from './Image.js';
// Boss <-> BossDifficulty
Boss.hasMany(BossDifficulty, { foreignKey: 'boss_id', as: 'difficulties' });
BossDifficulty.belongsTo(Boss, { foreignKey: 'boss_id' });
export { Boss, BossDifficulty };
export { Image };

View file

@ -13,8 +13,10 @@
"cors": "^2.8.5",
"express": "^5.1.0",
"mariadb": "^3.4.0",
"multer": "^2.0.0",
"mysql2": "^3.14.1",
"sequelize": "^6.37.5"
"sequelize": "^6.37.5",
"sharp": "^0.34.1"
}
},
"node_modules/@aws-crypto/crc32": {
@ -868,6 +870,481 @@
"node": ">=18.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"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",
@ -1636,6 +2113,12 @@
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -1692,6 +2175,23 @@
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -1742,6 +2242,21 @@
"node": ">= 0.8"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@ -1843,6 +2358,15 @@
"node": ">= 0.8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dottie": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz",
@ -2422,6 +2946,68 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mysql2": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz",
@ -2608,6 +3194,20 @@
"node": ">= 0.10"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/retry-as-promised": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
@ -2630,6 +3230,26 @@
"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",
@ -2770,6 +3390,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -2866,6 +3530,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strnum": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
@ -2913,6 +3594,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@ -2928,6 +3615,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",

View file

@ -14,6 +14,8 @@
"mysql2": "^3.14.1",
"mariadb": "^3.4.0",
"@aws-sdk/client-s3": "^3.800.0",
"axios": "^1.9.0"
"axios": "^1.9.0",
"multer": "^2.0.0",
"sharp": "^0.34.1"
}
}

View file

@ -1,6 +1,14 @@
import { Router } from 'express';
import multer from 'multer';
import { Image } from '../models/index.js';
import { convertAndUpload, deleteFromS3 } from '../services/image.js';
import { getPublicUrl } from '../lib/s3.js';
const router = Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
});
// 관리자 인증 미들웨어
function requireAdmin(req, res, next) {
@ -11,7 +19,7 @@ function requireAdmin(req, res, next) {
next();
}
// 관리자 키 검증
// 키 검증 (인증 불필요)
router.post('/verify', (req, res) => {
const { key } = req.body;
if (key === process.env.NEXON_API_KEY) {
@ -20,9 +28,116 @@ router.post('/verify', (req, res) => {
res.status(403).json({ error: '유효하지 않은 키입니다' });
});
// 관리자 API에 미들웨어 적용
// 이하 모든 라우트는 인증 필요
router.use(requireAdmin);
// TODO: 보스 관리 API 추가 예정
/* ── 이미지 관리 ── */
// 이미지 목록
router.get('/images', async (_req, res) => {
try {
const images = await Image.findAll({ order: [['created_at', 'DESC']] });
res.json(images.map((img) => ({
id: img.id,
name: img.name,
url: getPublicUrl(img.path),
width: img.width,
height: img.height,
size: img.size,
created_at: img.created_at,
})));
} catch (err) {
console.error('이미지 목록 조회 오류:', err.message);
res.status(500).json({ error: '이미지 목록 조회 실패' });
}
});
// 이미지 업로드 (다중 지원)
router.post('/images', upload.array('files', 50), async (req, res) => {
if (!req.files?.length) return res.status(400).json({ error: '파일이 없습니다' });
const names = Array.isArray(req.body.names) ? req.body.names : [req.body.names];
const results = [];
const errors = [];
// 입력 이름 정리 및 중복 사전 체크
const requestedNames = req.files.map((file, i) =>
(names[i] || file.originalname.replace(/\.[^.]+$/, '')).trim()
);
// 같은 요청 내 중복
const seen = new Set();
const dupInRequest = new Set();
requestedNames.forEach((n) => {
if (seen.has(n)) dupInRequest.add(n);
seen.add(n);
});
// DB와 중복
const existing = await Image.findAll({
where: { name: requestedNames },
attributes: ['name'],
});
const dupInDb = new Set(existing.map((i) => i.name));
for (let i = 0; i < req.files.length; i++) {
const file = req.files[i];
const name = requestedNames[i];
if (dupInRequest.has(name)) {
errors.push({ name, error: '같은 이름이 요청에 중복됩니다' });
continue;
}
if (dupInDb.has(name)) {
errors.push({ name, error: '이미 같은 이름의 이미지가 존재합니다' });
continue;
}
try {
const { path, width, height, size } = await convertAndUpload(file.buffer);
const image = await Image.create({ name, path, width, height, size });
results.push({
id: image.id,
name: image.name,
url: getPublicUrl(image.path),
width: image.width,
height: image.height,
size: image.size,
});
} catch (err) {
console.error(`이미지 업로드 오류 (${file.originalname}):`, err.message);
errors.push({ name, error: err.message });
}
}
res.json({ uploaded: results, errors });
});
// 이미지 다중 삭제
router.post('/images/delete', async (req, res) => {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ error: '삭제할 이미지를 선택해주세요' });
}
try {
const images = await Image.findAll({ where: { id: ids } });
await Promise.all(
images.map((img) =>
deleteFromS3(img.path).catch((err) =>
console.warn(`S3 삭제 실패 (${img.path}):`, err.message)
)
)
);
await Image.destroy({ where: { id: ids } });
res.json({ success: true, deleted: images.length });
} catch (err) {
console.error('이미지 삭제 오류:', err.message);
res.status(500).json({ error: '이미지 삭제 실패' });
}
});
export default router;

View file

@ -1,23 +0,0 @@
import { Router } from 'express';
import { Boss, BossDifficulty } from '../../models/index.js';
const router = Router();
// 보스 목록 + 난이도별 결정석 가격
router.get('/', async (_req, res) => {
try {
const bosses = await Boss.findAll({
include: [{ model: BossDifficulty, as: 'difficulties' }],
order: [
['sort_order', 'ASC'],
[{ model: BossDifficulty, as: 'difficulties' }, 'crystal_price', 'DESC'],
],
});
res.json(bosses);
} catch (err) {
console.error('보스 목록 조회 오류:', err.message);
res.status(500).json({ error: '보스 목록 조회 실패' });
}
});
export default router;

View file

@ -1,31 +0,0 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { UserCharacter, UserBossSelection, BossDifficulty, Boss } from '../../models/index.js';
import { calculateRevenue } from '../../services/boss/calculator.js';
const router = Router();
router.get('/', requireAuth, async (req, res) => {
try {
const characters = await UserCharacter.findAll({
where: { user_id: req.session.userId },
include: [{
model: UserBossSelection,
as: 'selections',
include: [{
model: BossDifficulty,
as: 'difficulty',
include: [{ model: Boss }],
}],
}],
});
const result = calculateRevenue(characters);
res.json(result);
} catch (err) {
console.error('수익 계산 오류:', err.message);
res.status(500).json({ error: '수익 계산 실패' });
}
});
export default router;

View file

@ -1,58 +0,0 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { UserBossSelection, BossDifficulty, Boss } from '../../models/index.js';
const router = Router();
// 내 캐릭터별 보스 선택 현황
router.get('/', requireAuth, async (req, res) => {
try {
const selections = await UserBossSelection.findAll({
where: { user_id: req.session.userId },
include: [{
model: BossDifficulty,
as: 'difficulty',
include: [{ model: Boss }],
}],
});
res.json(selections);
} catch (err) {
console.error('선택 조회 오류:', err.message);
res.status(500).json({ error: '보스 선택 조회 실패' });
}
});
// 캐릭터별 보스 선택 저장
router.put('/:characterId', requireAuth, async (req, res) => {
const { characterId } = req.params;
const { selections } = req.body; // [{ boss_difficulty_id, party_size }]
try {
// 기존 선택 삭제
await UserBossSelection.destroy({
where: {
user_id: req.session.userId,
user_character_id: characterId,
},
});
// 새 선택 생성
if (selections?.length) {
await UserBossSelection.bulkCreate(
selections.map((s) => ({
user_id: req.session.userId,
user_character_id: characterId,
boss_difficulty_id: s.boss_difficulty_id,
party_size: s.party_size || 1,
}))
);
}
res.json({ success: true });
} catch (err) {
console.error('선택 저장 오류:', err.message);
res.status(500).json({ error: '보스 선택 저장 실패' });
}
});
export default router;

View file

@ -1,33 +0,0 @@
import { Router } from 'express';
import { getCharacterOcid, getCharacterBasic } from '../services/nexon.js';
const router = Router();
// 캐릭터 닉네임으로 정보 조회
router.get('/search', async (req, res) => {
const { name } = req.query;
if (!name) {
return res.status(400).json({ error: '캐릭터 닉네임을 입력해주세요' });
}
try {
const ocid = await getCharacterOcid(name);
const basic = await getCharacterBasic(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,
});
} catch (err) {
if (err.response?.status === 400) {
return res.status(404).json({ error: '존재하지 않는 캐릭터입니다' });
}
console.error('캐릭터 조회 오류:', err.message);
res.status(500).json({ error: '캐릭터 조회 실패' });
}
});
export default router;

View file

@ -1,9 +1,8 @@
import express from 'express';
import cors from 'cors';
import characterRoutes from './routes/characters.js';
import bossRoutes from './routes/boss/bosses.js';
import adminRoutes from './routes/admin.js';
import { sequelize } from './lib/db.js';
import './models/index.js';
const app = express();
const PORT = process.env.PORT || 3000;
@ -16,8 +15,6 @@ app.use(cors({
}));
app.use(express.json());
app.use('/api/characters', characterRoutes);
app.use('/api/boss', bossRoutes);
app.use('/api/admin', adminRoutes);
app.get('/api/health', (_req, res) => {

View file

@ -1,60 +0,0 @@
const MAX_CRYSTALS_PER_CHARACTER = 12;
const MAX_CRYSTALS_PER_ACCOUNT = 90;
/**
* 주간 보스 수익 계산
* @param {Array} characterSelections - 캐릭터별 보스 선택 데이터
* @returns {Object} 계산 결과
*/
export function calculateRevenue(characterSelections) {
const characterResults = [];
const allCrystals = [];
for (const char of characterSelections) {
const crystals = char.selections
.map((s) => ({
characterId: char.id,
characterName: char.character_name,
bossName: s.difficulty.Boss?.name || '',
difficulty: s.difficulty.difficulty,
crystalPrice: Number(s.difficulty.crystal_price),
partySize: s.party_size,
revenue: Math.floor(Number(s.difficulty.crystal_price) / s.party_size),
}))
.sort((a, b) => b.revenue - a.revenue)
.slice(0, MAX_CRYSTALS_PER_CHARACTER);
characterResults.push({
id: char.id,
characterName: char.character_name,
crystalCount: crystals.length,
maxCrystals: MAX_CRYSTALS_PER_CHARACTER,
crystals,
});
allCrystals.push(...crystals);
}
// 계정 한도 적용: 전체에서 수익 높은 순으로 90개
allCrystals.sort((a, b) => b.revenue - a.revenue);
const activeCrystals = allCrystals.slice(0, MAX_CRYSTALS_PER_ACCOUNT);
const excludedCrystals = allCrystals.slice(MAX_CRYSTALS_PER_ACCOUNT);
const totalRevenue = activeCrystals.reduce((sum, c) => sum + c.revenue, 0);
// 캐릭터별 소계 재계산 (계정 한도 반영)
const activeSet = new Set(activeCrystals);
for (const charResult of characterResults) {
const active = charResult.crystals.filter((c) => activeSet.has(c));
charResult.activeCount = active.length;
charResult.revenue = active.reduce((sum, c) => sum + c.revenue, 0);
}
return {
characters: characterResults,
totalCrystals: activeCrystals.length,
maxCrystals: MAX_CRYSTALS_PER_ACCOUNT,
totalRevenue,
excludedCrystals,
};
}

31
backend/services/image.js Normal file
View file

@ -0,0 +1,31 @@
import sharp from 'sharp';
import crypto from 'crypto';
import { uploadObject, deleteObject } from '../lib/s3.js';
/**
* 이미지를 webp로 변환하고 RustFS에 업로드
* @param {Buffer} buffer - 원본 이미지 버퍼
* @returns {Promise<{path: string, width: number, height: number, size: number}>}
*/
export async function convertAndUpload(buffer) {
const webpBuffer = await sharp(buffer)
.webp({ quality: 90 })
.toBuffer();
const metadata = await sharp(webpBuffer).metadata();
const hash = crypto.createHash('sha256').update(webpBuffer).digest('hex').slice(0, 16);
const path = `common/${hash}.webp`;
await uploadObject(path, webpBuffer, 'image/webp');
return {
path,
width: metadata.width,
height: metadata.height,
size: webpBuffer.length,
};
}
export async function deleteFromS3(path) {
await deleteObject(path);
}

View file

@ -1,19 +0,0 @@
import axios from 'axios';
const NEXON_API_BASE = 'https://open.api.nexon.com';
export async function getCharacterOcid(characterName) {
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/id`, {
params: { character_name: characterName },
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
});
return data.ocid;
}
export async function getCharacterBasic(ocid) {
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/basic`, {
params: { ocid },
headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
});
return data;
}

View file

@ -1,159 +1,249 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect } from 'react'
import { api } from '../../api/client'
function UploadModal({ open, onClose, onUpload, uploading }) {
const [file, setFile] = useState(null)
const [name, setName] = useState('')
const [preview, setPreview] = useState(null)
const fileInputRef = useRef(null)
/* ── 공용 모달 ── */
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 bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div className={`w-full ${maxWidth} rounded-2xl bg-gray-900 border border-white/10 shadow-2xl max-h-[90vh] flex flex-col`} onClick={(e) => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between shrink-0">
<h3 className="font-semibold">{title}</h3>
<button onClick={onClose} className="text-gray-500 hover:text-white transition text-xl leading-none">×</button>
</div>
{children}
</div>
</div>
)
}
/* ── 업로드 모달 (다중 지원) ── */
function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
const [items, setItems] = useState([]) // { file, name, preview, id }
const [dragOver, setDragOver] = useState(false)
useEffect(() => {
if (!open) {
setFile(null)
setName('')
setPreview(null)
}
if (!open) setItems([])
}, [open])
const handleFile = (f) => {
if (!f || !f.type.startsWith('image/')) return
setFile(f)
setName(f.name.replace(/\.[^.]+$/, ''))
const reader = new FileReader()
reader.onload = (e) => setPreview(e.target.result)
reader.readAsDataURL(f)
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 (!file || !name.trim()) return
await onUpload({ file, name: name.trim() })
if (!canSubmit) return
await onUpload(items)
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-md rounded-2xl bg-gray-900 border border-white/10 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between">
<h3 className="font-semibold">이미지 업로드</h3>
<button onClick={onClose} className="text-gray-500 hover:text-white transition">×</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* 파일 업로드 영역 */}
<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)
handleFile(e.dataTransfer.files[0])
addFiles(e.dataTransfer.files)
}}
className={`relative rounded-xl border-2 border-dashed transition cursor-pointer min-h-[180px] flex flex-col items-center justify-center overflow-hidden ${
className={`relative rounded-xl border-2 border-dashed transition cursor-pointer min-h-[120px] flex flex-col items-center justify-center ${
dragOver ? 'border-emerald-500 bg-emerald-500/10' : 'border-white/10 hover:border-white/20 bg-white/[0.02]'
}`}
>
{preview ? (
<img src={preview} alt="" className="max-h-40 object-contain p-3" />
) : (
<>
<div className="text-3xl mb-2 opacity-50">🖼</div>
<p className="text-sm text-gray-400">클릭하거나 이미지를 끌어다 놓으세요</p>
<p className="text-xs text-gray-600 mt-1">PNG, JPG, WEBP </p>
</>
)}
<div className="text-2xl mb-1 opacity-50">📥</div>
<p className="text-sm text-gray-400">클릭하거나 이미지를 끌어다 놓으세요</p>
<p className="text-xs text-gray-600 mt-0.5">여러 선택 가능</p>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={(e) => handleFile(e.target.files[0])}
multiple
onChange={(e) => { addFiles(e.target.files); e.target.value = '' }}
className="hidden"
/>
</label>
{/* 이름 */}
<div>
<label className="block text-xs text-gray-400 mb-1.5">이미지 이름</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="예: 강렬한 힘의 결정"
className="w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition"
/>
</div>
{/* 선택된 파일 리스트 */}
{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
{/* 버튼 */}
<div className="flex gap-2 pt-2">
<button type="button" onClick={onClose} className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm hover:bg-white/5 transition">
취소
</button>
<button
type="submit"
disabled={!file || !name.trim() || uploading}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
{uploading ? '업로드 중...' : '업로드'}
</button>
</div>
</form>
</div>
</div>
return (
<div key={item.id} className={`flex items-start gap-3 rounded-lg border bg-gray-950/50 p-2 ${
errorMsg ? 'border-red-500/40' : 'border-white/5'
}`}>
<div className="w-12 h-12 rounded bg-gray-900 flex items-center justify-center overflow-hidden shrink-0">
{item.preview ? (
<img src={item.preview} alt="" className="w-full h-full object-contain" />
) : (
<div className="w-4 h-4 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
)}
</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 bg-gray-900 px-2 py-1.5 text-sm outline-none transition ${
errorMsg ? 'border-red-500/40 focus:border-red-500/60' : 'border-white/10 focus:border-emerald-500/50'
}`}
/>
{errorMsg && <div className="text-[11px] text-red-400 px-0.5">{errorMsg}</div>}
</div>
<button
type="button"
onClick={() => removeItem(item.id)}
className="w-7 h-7 rounded text-gray-500 hover:text-red-400 hover:bg-red-500/10 transition shrink-0"
>
×
</button>
</div>
)
})}
</div>
)}
</div>
{/* 버튼 */}
<div className="flex gap-2 px-6 py-4 border-t border-white/5 shrink-0">
<button type="button" onClick={onClose} className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm hover:bg-white/5 transition">
취소
</button>
<button
type="submit"
disabled={!canSubmit || uploading}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
{uploading ? '업로드 중...' : `${items.length > 0 ? `${items.length}` : ''}업로드`}
</button>
</div>
</form>
</Modal>
)
}
function ImageCard({ image, onDelete }) {
const [showMenu, setShowMenu] = useState(false)
const [copied, setCopied] = useState(false)
const copyUrl = () => {
navigator.clipboard.writeText(image.url)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
/* ── 삭제 확인 다이얼로그 ── */
function ConfirmDialog({ open, onClose, onConfirm, title, description, confirmText = '삭제', destructive = false, loading = false }) {
return (
<div className="group relative rounded-xl border border-white/5 bg-gray-900/40 overflow-hidden hover:border-white/15 transition">
{/* 이미지 영역 */}
<Modal open={open} onClose={onClose} title={title}>
<div className="p-6">
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-line">{description}</p>
</div>
<div className="flex gap-2 px-6 py-4 border-t border-white/5">
<button onClick={onClose} className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm hover:bg-white/5 transition">
취소
</button>
<button
onClick={onConfirm}
disabled={loading}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50 ${
destructive
? 'bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/20'
: 'bg-emerald-600 hover:bg-emerald-500'
}`}
>
{loading ? '처리 중...' : confirmText}
</button>
</div>
</Modal>
)
}
/* ── 이미지 카드 ── */
function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) {
return (
<div
onClick={() => selectMode && onToggle(image.id)}
className={`group relative rounded-xl border overflow-hidden transition ${
selected
? 'border-emerald-500/60 bg-emerald-500/5 ring-2 ring-emerald-500/30'
: 'border-white/5 bg-gray-900/40 hover:border-white/15'
} ${selectMode ? 'cursor-pointer' : ''}`}
>
{/* 체크박스 (선택모드) */}
{selectMode && (
<div className={`absolute top-2 left-2 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition ${
selected ? 'border-emerald-500 bg-emerald-500' : 'border-white/30 bg-gray-950/80'
}`}>
{selected && <span className="text-xs text-white"></span>}
</div>
)}
<div className="aspect-square bg-gradient-to-br from-gray-900 to-gray-950 flex items-center justify-center p-4 relative">
<img src={image.url} alt={image.name} className="max-w-full max-h-full object-contain" />
{/* 액션 버튼 */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={copyUrl}
className="w-7 h-7 rounded-md bg-gray-950/80 backdrop-blur-sm border border-white/10 hover:bg-emerald-500/20 hover:border-emerald-500/40 text-xs flex items-center justify-center transition"
title="URL 복사"
>
{copied ? '✓' : '⧉'}
</button>
<button
onClick={() => onDelete(image)}
className="w-7 h-7 rounded-md bg-gray-950/80 backdrop-blur-sm border border-white/10 hover:bg-red-500/20 hover:border-red-500/40 text-xs flex items-center justify-center transition"
title="삭제"
>
×
</button>
</div>
{!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 bg-gray-950/80 backdrop-blur-sm border border-white/10 hover:bg-emerald-500/20 hover:border-emerald-500/40 text-xs flex items-center justify-center transition"
title="URL 복사"
>
{copied ? '✓' : '⧉'}
</button>
</div>
)}
</div>
{/* 정보 */}
<div className="px-3 py-2 border-t border-white/5">
<div className="text-sm font-medium truncate">{image.name}</div>
<div className="text-xs text-gray-500 truncate mt-0.5">{image.url}</div>
</div>
</div>
)
}
/* ── 메인 ── */
export default function AdminImages() {
const [images, setImages] = useState([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [uploadOpen, setUploadOpen] = useState(false)
const [uploading, setUploading] = useState(false)
const [search, setSearch] = useState('')
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState(new Set())
const [confirmDelete, setConfirmDelete] = useState(null) // {ids, names}
const [deleting, setDeleting] = useState(false)
const [copiedId, setCopiedId] = useState(null)
const fetchImages = async () => {
setLoading(true)
@ -167,16 +257,16 @@ export default function AdminImages() {
}
}
useEffect(() => {
fetchImages()
}, [])
useEffect(() => { fetchImages() }, [])
const handleUpload = async ({ file, name }) => {
const handleUpload = async (items) => {
setUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('name', name)
items.forEach((it) => {
formData.append('files', it.file)
formData.append('names', it.name.trim())
})
const adminKey = localStorage.getItem('maple-admin-key')
const res = await fetch('/api/admin/images', {
@ -184,11 +274,14 @@ export default function AdminImages() {
headers: { 'x-admin-key': adminKey },
body: formData,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || '업로드 실패')
const result = await res.json()
if (!res.ok) throw new Error(result.error || '업로드 실패')
if (result.errors?.length > 0) {
alert(`일부 업로드 실패:\n${result.errors.map((e) => `- ${e.name}: ${e.error}`).join('\n')}`)
}
setModalOpen(false)
setUploadOpen(false)
await fetchImages()
} catch (err) {
alert(err.message)
@ -197,20 +290,63 @@ export default function AdminImages() {
}
}
const handleDelete = async (image) => {
if (!confirm(`"${image.name}" 이미지를 삭제하시겠습니까?`)) return
try {
await api(`/api/admin/images/${image.id}`, { method: 'DELETE' })
await fetchImages()
} catch (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 filtered = images.filter((img) =>
img.name.toLowerCase().includes(search.toLowerCase())
)
const selectAll = () => {
if (selectedIds.size === filtered.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filtered.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 handleDeleteConfirm = async () => {
setDeleting(true)
try {
await api('/api/admin/images/delete', {
method: 'POST',
body: { ids: confirmDelete.ids },
})
setConfirmDelete(null)
setSelectedIds(new Set())
setSelectMode(false)
await fetchImages()
} catch (err) {
alert(err.message)
} finally {
setDeleting(false)
}
}
const copyUrl = (image) => {
navigator.clipboard.writeText(image.url)
setCopiedId(image.id)
setTimeout(() => setCopiedId(null), 1500)
}
return (
<div className="space-y-6">
<div className="flex items-end justify-between gap-4 flex-wrap">
@ -218,13 +354,50 @@ export default function AdminImages() {
<h2 className="text-lg font-semibold">이미지 관리</h2>
<p className="text-sm text-gray-500 mt-0.5">공용 이미지를 업로드하고 관리합니다</p>
</div>
<button
onClick={() => setModalOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 px-4 py-2 text-sm font-medium transition shadow-lg shadow-emerald-500/20"
>
<span className="text-base leading-none">+</span>
이미지 업로드
</button>
<div className="flex items-center gap-2">
{selectMode ? (
<>
<span className="text-sm text-gray-400">{selectedIds.size} 선택</span>
<button
onClick={selectAll}
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
>
{selectedIds.size === filtered.length && filtered.length > 0 ? '전체 해제' : '전체 선택'}
</button>
<button
onClick={requestDelete}
disabled={selectedIds.size === 0}
className="rounded-lg bg-red-600 hover:bg-red-500 px-3 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed transition shadow-lg shadow-red-500/20"
>
삭제
</button>
<button
onClick={toggleSelectMode}
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
>
완료
</button>
</>
) : (
<>
{images.length > 0 && (
<button
onClick={toggleSelectMode}
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
>
선택
</button>
)}
<button
onClick={() => setUploadOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 px-4 py-2 text-sm font-medium transition shadow-lg shadow-emerald-500/20"
>
<span className="text-base leading-none">+</span>
이미지 업로드
</button>
</>
)}
</div>
</div>
{/* 검색 */}
@ -256,7 +429,7 @@ export default function AdminImages() {
</p>
{images.length === 0 && (
<button
onClick={() => setModalOpen(true)}
onClick={() => setUploadOpen(true)}
className="text-sm text-emerald-400 hover:text-emerald-300 transition"
>
이미지 업로드하기
@ -266,16 +439,36 @@ export default function AdminImages() {
) : (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{filtered.map((image) => (
<ImageCard key={image.id} image={image} onDelete={handleDelete} />
<ImageCard
key={image.id}
image={image}
selected={selectedIds.has(image.id)}
selectMode={selectMode}
onToggle={toggleSelect}
onCopyUrl={copyUrl}
copied={copiedId === image.id}
/>
))}
</div>
)}
<UploadModal
open={modalOpen}
onClose={() => setModalOpen(false)}
open={uploadOpen}
onClose={() => setUploadOpen(false)}
onUpload={handleUpload}
uploading={uploading}
existingNames={new Set(images.map((img) => img.name))}
/>
<ConfirmDialog
open={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
onConfirm={handleDeleteConfirm}
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={deleting}
/>
</div>
)