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
|
||||
NODE_ENV=production
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"express": "^4.18.2",
|
||||
"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 bcrypt from "bcrypt";
|
||||
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";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// JWT 비밀키 (실제 운영에서는 환경변수로 관리)
|
||||
// JWT 설정
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "fromis9-admin-secret-key-2026";
|
||||
const JWT_EXPIRES_IN = "30d"; // 30일
|
||||
const JWT_EXPIRES_IN = "30d";
|
||||
|
||||
// 관리자 로그인
|
||||
router.post("/login", async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "아이디와 비밀번호를 입력해주세요." });
|
||||
// Multer 설정 (메모리 저장)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith("image/")) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
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) => {
|
||||
const authHeader = req.headers["authorization"];
|
||||
const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "인증이 필요합니다." });
|
||||
|
|
@ -82,18 +60,62 @@ export const authenticateToken = (req, res, next) => {
|
|||
});
|
||||
};
|
||||
|
||||
// 토큰 검증 엔드포인트
|
||||
router.get("/verify", authenticateToken, (req, res) => {
|
||||
res.json({
|
||||
valid: true,
|
||||
user: req.user,
|
||||
});
|
||||
// 관리자 로그인
|
||||
router.post("/login", async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
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) => {
|
||||
try {
|
||||
// 이미 계정이 있는지 확인
|
||||
const [existing] = await pool.query(
|
||||
"SELECT COUNT(*) as count FROM admin_users"
|
||||
);
|
||||
|
|
@ -102,12 +124,9 @@ router.post("/init", async (req, res) => {
|
|||
return res.status(400).json({ error: "이미 관리자 계정이 존재합니다." });
|
||||
}
|
||||
|
||||
// 비밀번호 해시 생성
|
||||
const password = "auddnek0403!";
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// 계정 생성
|
||||
await pool.query(
|
||||
"INSERT INTO admin_users (username, password_hash) VALUES (?, ?)",
|
||||
["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;
|
||||
|
|
|
|||
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,
|
||||
ChevronDown, ChevronLeft, Calendar
|
||||
} from 'lucide-react';
|
||||
import Toast from '../../../components/Toast';
|
||||
|
||||
// 커스텀 드롭다운 컴포넌트
|
||||
function CustomSelect({ value, onChange, options, placeholder }) {
|
||||
|
|
@ -355,6 +356,15 @@ function AdminAlbumForm() {
|
|||
const [saving, setSaving] = useState(false);
|
||||
const [coverPreview, setCoverPreview] = 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({
|
||||
title: '',
|
||||
|
|
@ -453,6 +463,29 @@ function AdminAlbumForm() {
|
|||
|
||||
const handleSubmit = async (e) => {
|
||||
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);
|
||||
|
||||
try {
|
||||
|
|
@ -481,7 +514,7 @@ function AdminAlbumForm() {
|
|||
navigate('/admin/albums');
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
setToast({ message: '저장 중 오류가 발생했습니다.', type: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -504,6 +537,9 @@ function AdminAlbumForm() {
|
|||
variants={pageVariants}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Toast */}
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
|
||||
{/* 헤더 */}
|
||||
<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">
|
||||
|
|
@ -640,7 +676,6 @@ function AdminAlbumForm() {
|
|||
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"
|
||||
placeholder="예: 하얀 그리움"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -658,7 +693,6 @@ function AdminAlbumForm() {
|
|||
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"
|
||||
placeholder="예: white-memories"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">영문 소문자, 숫자, 하이픈만 사용</p>
|
||||
|
|
@ -670,25 +704,25 @@ function AdminAlbumForm() {
|
|||
앨범 타입 *
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={formData.album_type}
|
||||
onChange={(val) => setFormData(prev => ({ ...prev, album_type: val }))}
|
||||
value={formData.album_type_short}
|
||||
onChange={(val) => setFormData(prev => ({ ...prev, album_type_short: val }))}
|
||||
options={albumTypes}
|
||||
placeholder="타입 선택"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 앨범 타입 약어 */}
|
||||
{/* 앨범 유형 (전체) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
타입 약어
|
||||
앨범 유형 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="album_type_short"
|
||||
value={formData.album_type_short}
|
||||
name="album_type"
|
||||
value={formData.album_type}
|
||||
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"
|
||||
placeholder="예: 스페셜 디지털 싱글"
|
||||
placeholder="예: 미니 6집"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -793,13 +827,98 @@ function AdminAlbumForm() {
|
|||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={track.duration}
|
||||
value={track.duration || ''}
|
||||
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"
|
||||
placeholder="3:30"
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue