feat: 앨범 관리 기능 - CRUD API, RustFS 커버 업로드, 트랙 상세 정보, Toast 알림
This commit is contained in:
parent
bd787d57c3
commit
40fa94f9f5
5 changed files with 528 additions and 80 deletions
6
.env
6
.env
|
|
@ -9,3 +9,9 @@ DB_NAME=fromis9
|
||||||
PORT=80
|
PORT=80
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
JWT_SECRET=fromis9-admin-jwt-secret-2026-xK9mP2vL7nQ4w
|
JWT_SECRET=fromis9-admin-jwt-secret-2026-xK9mP2vL7nQ4w
|
||||||
|
|
||||||
|
# RustFS (S3 Compatible)
|
||||||
|
RUSTFS_ENDPOINT=https://rustfs.caadiq.co.kr
|
||||||
|
RUSTFS_ACCESS_KEY=iOpbGJIn4VumvxXlSC6D
|
||||||
|
RUSTFS_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd
|
||||||
|
RUSTFS_BUCKET=fromis-9
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,12 @@
|
||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.700.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mysql2": "^3.11.0"
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"mysql2": "^3.11.0",
|
||||||
|
"sharp": "^0.33.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,73 +1,51 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
|
import multer from "multer";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
import pool from "../lib/db.js";
|
import pool from "../lib/db.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// JWT 비밀키 (실제 운영에서는 환경변수로 관리)
|
// JWT 설정
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || "fromis9-admin-secret-key-2026";
|
const JWT_SECRET = process.env.JWT_SECRET || "fromis9-admin-secret-key-2026";
|
||||||
const JWT_EXPIRES_IN = "30d"; // 30일
|
const JWT_EXPIRES_IN = "30d";
|
||||||
|
|
||||||
// 관리자 로그인
|
// Multer 설정 (메모리 저장)
|
||||||
router.post("/login", async (req, res) => {
|
const upload = multer({
|
||||||
try {
|
storage: multer.memoryStorage(),
|
||||||
const { username, password } = req.body;
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
if (!username || !password) {
|
if (file.mimetype.startsWith("image/")) {
|
||||||
return res
|
cb(null, true);
|
||||||
.status(400)
|
} else {
|
||||||
.json({ error: "아이디와 비밀번호를 입력해주세요." });
|
cb(new Error("이미지 파일만 업로드 가능합니다."), false);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
// 사용자 조회
|
|
||||||
const [users] = await pool.query(
|
|
||||||
"SELECT * FROM admin_users WHERE username = ?",
|
|
||||||
[username]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
return res
|
|
||||||
.status(401)
|
|
||||||
.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = users[0];
|
|
||||||
|
|
||||||
// 비밀번호 검증
|
|
||||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
|
||||||
|
|
||||||
if (!isValidPassword) {
|
|
||||||
return res
|
|
||||||
.status(401)
|
|
||||||
.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT 토큰 발급
|
|
||||||
const token = jwt.sign(
|
|
||||||
{ id: user.id, username: user.username },
|
|
||||||
JWT_SECRET,
|
|
||||||
{ expiresIn: JWT_EXPIRES_IN }
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: "로그인 성공",
|
|
||||||
token,
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("로그인 오류:", error);
|
|
||||||
res.status(500).json({ error: "로그인 처리 중 오류가 발생했습니다." });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// S3 클라이언트 (RustFS)
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
endpoint: process.env.RUSTFS_ENDPOINT,
|
||||||
|
region: "us-east-1",
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.RUSTFS_ACCESS_KEY,
|
||||||
|
secretAccessKey: process.env.RUSTFS_SECRET_KEY,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const BUCKET = process.env.RUSTFS_BUCKET || "fromis-9";
|
||||||
|
|
||||||
// 토큰 검증 미들웨어
|
// 토큰 검증 미들웨어
|
||||||
export const authenticateToken = (req, res, next) => {
|
export const authenticateToken = (req, res, next) => {
|
||||||
const authHeader = req.headers["authorization"];
|
const authHeader = req.headers["authorization"];
|
||||||
const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN
|
const token = authHeader && authHeader.split(" ")[1];
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(401).json({ error: "인증이 필요합니다." });
|
return res.status(401).json({ error: "인증이 필요합니다." });
|
||||||
|
|
@ -82,18 +60,62 @@ export const authenticateToken = (req, res, next) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 토큰 검증 엔드포인트
|
// 관리자 로그인
|
||||||
router.get("/verify", authenticateToken, (req, res) => {
|
router.post("/login", async (req, res) => {
|
||||||
res.json({
|
try {
|
||||||
valid: true,
|
const { username, password } = req.body;
|
||||||
user: req.user,
|
|
||||||
});
|
if (!username || !password) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "아이디와 비밀번호를 입력해주세요." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [users] = await pool.query(
|
||||||
|
"SELECT * FROM admin_users WHERE username = ?",
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user.id, username: user.username },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: JWT_EXPIRES_IN }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "로그인 성공",
|
||||||
|
token,
|
||||||
|
user: { id: user.id, username: user.username },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("로그인 오류:", error);
|
||||||
|
res.status(500).json({ error: "로그인 처리 중 오류가 발생했습니다." });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 초기 관리자 계정 생성 (한 번만 실행)
|
// 토큰 검증 엔드포인트
|
||||||
|
router.get("/verify", authenticateToken, (req, res) => {
|
||||||
|
res.json({ valid: true, user: req.user });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 초기 관리자 계정 생성
|
||||||
router.post("/init", async (req, res) => {
|
router.post("/init", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// 이미 계정이 있는지 확인
|
|
||||||
const [existing] = await pool.query(
|
const [existing] = await pool.query(
|
||||||
"SELECT COUNT(*) as count FROM admin_users"
|
"SELECT COUNT(*) as count FROM admin_users"
|
||||||
);
|
);
|
||||||
|
|
@ -102,12 +124,9 @@ router.post("/init", async (req, res) => {
|
||||||
return res.status(400).json({ error: "이미 관리자 계정이 존재합니다." });
|
return res.status(400).json({ error: "이미 관리자 계정이 존재합니다." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비밀번호 해시 생성
|
|
||||||
const password = "auddnek0403!";
|
const password = "auddnek0403!";
|
||||||
const saltRounds = 10;
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
|
||||||
|
|
||||||
// 계정 생성
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
"INSERT INTO admin_users (username, password_hash) VALUES (?, ?)",
|
"INSERT INTO admin_users (username, password_hash) VALUES (?, ?)",
|
||||||
["admin", passwordHash]
|
["admin", passwordHash]
|
||||||
|
|
@ -120,4 +139,271 @@ router.post("/init", async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 앨범 관리 API ====================
|
||||||
|
|
||||||
|
// 앨범 생성
|
||||||
|
router.post(
|
||||||
|
"/albums",
|
||||||
|
authenticateToken,
|
||||||
|
upload.single("cover"),
|
||||||
|
async (req, res) => {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
const data = JSON.parse(req.body.data);
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
album_type,
|
||||||
|
album_type_short,
|
||||||
|
release_date,
|
||||||
|
folder_name,
|
||||||
|
description,
|
||||||
|
tracks,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!title || !album_type || !release_date || !folder_name) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "필수 필드를 모두 입력해주세요." });
|
||||||
|
}
|
||||||
|
|
||||||
|
let coverUrl = null;
|
||||||
|
|
||||||
|
// 커버 이미지 업로드
|
||||||
|
if (req.file) {
|
||||||
|
// WebP 변환 (원본 크기, 무손실)
|
||||||
|
const processedImage = await sharp(req.file.buffer)
|
||||||
|
.webp({ lossless: true })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const coverKey = `album/${folder_name}/cover.webp`;
|
||||||
|
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: BUCKET,
|
||||||
|
Key: coverKey,
|
||||||
|
Body: processedImage,
|
||||||
|
ContentType: "image/webp",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
coverUrl = `${process.env.RUSTFS_ENDPOINT}/${BUCKET}/${coverKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 앨범 삽입
|
||||||
|
const [albumResult] = await connection.query(
|
||||||
|
`INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name, cover_url, description)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
title,
|
||||||
|
album_type,
|
||||||
|
album_type_short || null,
|
||||||
|
release_date,
|
||||||
|
folder_name,
|
||||||
|
coverUrl,
|
||||||
|
description || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const albumId = albumResult.insertId;
|
||||||
|
|
||||||
|
// 트랙 삽입
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
for (const track of tracks) {
|
||||||
|
await connection.query(
|
||||||
|
`INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
albumId,
|
||||||
|
track.track_number,
|
||||||
|
track.title,
|
||||||
|
track.duration || null,
|
||||||
|
track.is_title_track ? 1 : 0,
|
||||||
|
track.lyricist || null,
|
||||||
|
track.composer || null,
|
||||||
|
track.arranger || null,
|
||||||
|
track.lyrics || null,
|
||||||
|
track.music_video_url || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
res.json({ message: "앨범이 생성되었습니다.", albumId });
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
console.error("앨범 생성 오류:", error);
|
||||||
|
res.status(500).json({ error: "앨범 생성 중 오류가 발생했습니다." });
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 앨범 수정
|
||||||
|
router.put(
|
||||||
|
"/albums/:id",
|
||||||
|
authenticateToken,
|
||||||
|
upload.single("cover"),
|
||||||
|
async (req, res) => {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
const albumId = req.params.id;
|
||||||
|
const data = JSON.parse(req.body.data);
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
album_type,
|
||||||
|
album_type_short,
|
||||||
|
release_date,
|
||||||
|
folder_name,
|
||||||
|
description,
|
||||||
|
tracks,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
// 기존 앨범 조회
|
||||||
|
const [existingAlbums] = await connection.query(
|
||||||
|
"SELECT * FROM albums WHERE id = ?",
|
||||||
|
[albumId]
|
||||||
|
);
|
||||||
|
if (existingAlbums.length === 0) {
|
||||||
|
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
let coverUrl = existingAlbums[0].cover_url;
|
||||||
|
|
||||||
|
// WebP 변환 (원본 크기, 무손실)
|
||||||
|
if (req.file) {
|
||||||
|
const processedImage = await sharp(req.file.buffer)
|
||||||
|
.webp({ lossless: true })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const coverKey = `album/${folder_name}/cover.webp`;
|
||||||
|
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: BUCKET,
|
||||||
|
Key: coverKey,
|
||||||
|
Body: processedImage,
|
||||||
|
ContentType: "image/webp",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
coverUrl = `${process.env.RUSTFS_ENDPOINT}/${BUCKET}/${coverKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 앨범 업데이트
|
||||||
|
await connection.query(
|
||||||
|
`UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?, folder_name = ?, cover_url = ?, description = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
title,
|
||||||
|
album_type,
|
||||||
|
album_type_short || null,
|
||||||
|
release_date,
|
||||||
|
folder_name,
|
||||||
|
coverUrl,
|
||||||
|
description || null,
|
||||||
|
albumId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기존 트랙 삭제 후 새 트랙 삽입
|
||||||
|
await connection.query("DELETE FROM tracks WHERE album_id = ?", [
|
||||||
|
albumId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
for (const track of tracks) {
|
||||||
|
await connection.query(
|
||||||
|
`INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
albumId,
|
||||||
|
track.track_number,
|
||||||
|
track.title,
|
||||||
|
track.duration || null,
|
||||||
|
track.is_title_track ? 1 : 0,
|
||||||
|
track.lyricist || null,
|
||||||
|
track.composer || null,
|
||||||
|
track.arranger || null,
|
||||||
|
track.lyrics || null,
|
||||||
|
track.music_video_url || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
res.json({ message: "앨범이 수정되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
console.error("앨범 수정 오류:", error);
|
||||||
|
res.status(500).json({ error: "앨범 수정 중 오류가 발생했습니다." });
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 앨범 삭제
|
||||||
|
router.delete("/albums/:id", authenticateToken, async (req, res) => {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
const albumId = req.params.id;
|
||||||
|
|
||||||
|
// 기존 앨범 조회
|
||||||
|
const [existingAlbums] = await connection.query(
|
||||||
|
"SELECT * FROM albums WHERE id = ?",
|
||||||
|
[albumId]
|
||||||
|
);
|
||||||
|
if (existingAlbums.length === 0) {
|
||||||
|
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const album = existingAlbums[0];
|
||||||
|
|
||||||
|
// RustFS에서 커버 이미지 삭제
|
||||||
|
if (album.cover_url && album.folder_name) {
|
||||||
|
try {
|
||||||
|
await s3Client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: BUCKET,
|
||||||
|
Key: `album/${album.folder_name}/cover.webp`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (s3Error) {
|
||||||
|
console.error("S3 삭제 오류:", s3Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트랙 삭제
|
||||||
|
await connection.query("DELETE FROM tracks WHERE album_id = ?", [albumId]);
|
||||||
|
|
||||||
|
// 앨범 삭제
|
||||||
|
await connection.query("DELETE FROM albums WHERE id = ?", [albumId]);
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
res.json({ message: "앨범이 삭제되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
console.error("앨범 삭제 오류:", error);
|
||||||
|
res.status(500).json({ error: "앨범 삭제 중 오류가 발생했습니다." });
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
34
frontend/src/components/Toast.jsx
Normal file
34
frontend/src/components/Toast.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast 컴포넌트 (Minecraft Web 스타일)
|
||||||
|
* - 하단 중앙에 표시
|
||||||
|
* - type: 'success' | 'error' | 'warning'
|
||||||
|
*/
|
||||||
|
function Toast({ toast, onClose }) {
|
||||||
|
if (!toast) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{toast && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 50 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className={`fixed bottom-8 inset-x-0 mx-auto w-fit z-[9999] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg cursor-pointer ${
|
||||||
|
toast.type === 'error'
|
||||||
|
? 'bg-red-500/90'
|
||||||
|
: toast.type === 'warning'
|
||||||
|
? 'bg-amber-500/90'
|
||||||
|
: 'bg-emerald-500/90'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.message}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Toast;
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Save, Home, ChevronRight, LogOut, Music, Trash2, Plus, Image, Star,
|
Save, Home, ChevronRight, LogOut, Music, Trash2, Plus, Image, Star,
|
||||||
ChevronDown, ChevronLeft, Calendar
|
ChevronDown, ChevronLeft, Calendar
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import Toast from '../../../components/Toast';
|
||||||
|
|
||||||
// 커스텀 드롭다운 컴포넌트
|
// 커스텀 드롭다운 컴포넌트
|
||||||
function CustomSelect({ value, onChange, options, placeholder }) {
|
function CustomSelect({ value, onChange, options, placeholder }) {
|
||||||
|
|
@ -355,6 +356,15 @@ function AdminAlbumForm() {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [coverPreview, setCoverPreview] = useState(null);
|
const [coverPreview, setCoverPreview] = useState(null);
|
||||||
const [coverFile, setCoverFile] = useState(null);
|
const [coverFile, setCoverFile] = useState(null);
|
||||||
|
const [toast, setToast] = useState(null);
|
||||||
|
|
||||||
|
// Toast 자동 숨김
|
||||||
|
useEffect(() => {
|
||||||
|
if (toast) {
|
||||||
|
const timer = setTimeout(() => setToast(null), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
|
|
@ -453,6 +463,29 @@ function AdminAlbumForm() {
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 커스텀 검증
|
||||||
|
if (!formData.title.trim()) {
|
||||||
|
setToast({ message: '앨범명을 입력해주세요.', type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.folder_name.trim()) {
|
||||||
|
setToast({ message: 'RustFS 폴더명을 입력해주세요.', type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.album_type_short) {
|
||||||
|
setToast({ message: '앨범 타입을 선택해주세요.', type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.release_date) {
|
||||||
|
setToast({ message: '발매일을 선택해주세요.', type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.album_type.trim()) {
|
||||||
|
setToast({ message: '앨범 유형을 입력해주세요.', type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -481,7 +514,7 @@ function AdminAlbumForm() {
|
||||||
navigate('/admin/albums');
|
navigate('/admin/albums');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('저장 오류:', error);
|
console.error('저장 오류:', error);
|
||||||
alert('저장 중 오류가 발생했습니다.');
|
setToast({ message: '저장 중 오류가 발생했습니다.', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -504,6 +537,9 @@ function AdminAlbumForm() {
|
||||||
variants={pageVariants}
|
variants={pageVariants}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
>
|
>
|
||||||
|
{/* Toast */}
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<header className="bg-white shadow-sm border-b border-gray-100">
|
<header className="bg-white shadow-sm border-b border-gray-100">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
|
@ -640,7 +676,6 @@ function AdminAlbumForm() {
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
placeholder="예: 하얀 그리움"
|
placeholder="예: 하얀 그리움"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -658,7 +693,6 @@ function AdminAlbumForm() {
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
placeholder="예: white-memories"
|
placeholder="예: white-memories"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-1">영문 소문자, 숫자, 하이픈만 사용</p>
|
<p className="text-xs text-gray-400 mt-1">영문 소문자, 숫자, 하이픈만 사용</p>
|
||||||
|
|
@ -670,25 +704,25 @@ function AdminAlbumForm() {
|
||||||
앨범 타입 *
|
앨범 타입 *
|
||||||
</label>
|
</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={formData.album_type}
|
value={formData.album_type_short}
|
||||||
onChange={(val) => setFormData(prev => ({ ...prev, album_type: val }))}
|
onChange={(val) => setFormData(prev => ({ ...prev, album_type_short: val }))}
|
||||||
options={albumTypes}
|
options={albumTypes}
|
||||||
placeholder="타입 선택"
|
placeholder="타입 선택"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 앨범 타입 약어 */}
|
{/* 앨범 유형 (전체) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
타입 약어
|
앨범 유형 *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="album_type_short"
|
name="album_type"
|
||||||
value={formData.album_type_short}
|
value={formData.album_type}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
placeholder="예: 스페셜 디지털 싱글"
|
placeholder="예: 미니 6집"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -793,13 +827,98 @@ function AdminAlbumForm() {
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={track.duration}
|
value={track.duration || ''}
|
||||||
onChange={(e) => updateTrack(index, 'duration', e.target.value)}
|
onChange={(e) => updateTrack(index, 'duration', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-center"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-center"
|
||||||
placeholder="3:30"
|
placeholder="3:30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 정보 토글 */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateTrack(index, 'showDetails', !track.showDetails)}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform ${track.showDetails ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
상세 정보 {track.showDetails ? '접기' : '펼치기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{track.showDetails && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* 작사/작곡/편곡 */}
|
||||||
|
<div className="space-y-3 mt-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">작사</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={track.lyricist || ''}
|
||||||
|
onChange={(e) => updateTrack(index, 'lyricist', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="여러 명일 경우 쉼표로 구분"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">작곡</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={track.composer || ''}
|
||||||
|
onChange={(e) => updateTrack(index, 'composer', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="여러 명일 경우 쉼표로 구분"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">편곡</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={track.arranger || ''}
|
||||||
|
onChange={(e) => updateTrack(index, 'arranger', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="여러 명일 경우 쉼표로 구분"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MV URL */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">뮤직비디오 URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={track.music_video_url || ''}
|
||||||
|
onChange={(e) => updateTrack(index, 'music_video_url', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="https://youtube.com/watch?v=..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가사 */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">가사</label>
|
||||||
|
<textarea
|
||||||
|
value={track.lyrics || ''}
|
||||||
|
onChange={(e) => updateTrack(index, 'lyrics', e.target.value)}
|
||||||
|
rows={12}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y min-h-[200px]"
|
||||||
|
placeholder="가사를 입력하세요..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue