Compare commits
No commits in common. "1ae01fb2d76d6d9627c3494a766c009b8cbd8075" and "d4bbf592d502c7922422fb49021c2c62d1ad464c" have entirely different histories.
1ae01fb2d7
...
d4bbf592d5
17 changed files with 262 additions and 4739 deletions
1
.env
1
.env
|
|
@ -12,7 +12,6 @@ JWT_SECRET=fromis9-admin-jwt-secret-2026-xK9mP2vL7nQ4w
|
||||||
|
|
||||||
# RustFS (S3 Compatible)
|
# RustFS (S3 Compatible)
|
||||||
RUSTFS_ENDPOINT=https://rustfs.caadiq.co.kr
|
RUSTFS_ENDPOINT=https://rustfs.caadiq.co.kr
|
||||||
RUSTFS_PUBLIC_URL=https://s3.caadiq.co.kr
|
|
||||||
RUSTFS_ACCESS_KEY=iOpbGJIn4VumvxXlSC6D
|
RUSTFS_ACCESS_KEY=iOpbGJIn4VumvxXlSC6D
|
||||||
RUSTFS_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd
|
RUSTFS_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd
|
||||||
RUSTFS_BUCKET=fromis-9
|
RUSTFS_BUCKET=fromis-9
|
||||||
|
|
|
||||||
2322
backend/package-lock.json
generated
2322
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -406,398 +406,4 @@ router.delete("/albums/:id", authenticateToken, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 앨범 사진 관리 API
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// 앨범 사진 목록 조회
|
|
||||||
router.get("/albums/:albumId/photos", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { albumId } = req.params;
|
|
||||||
|
|
||||||
// 앨범 존재 확인
|
|
||||||
const [albums] = await pool.query(
|
|
||||||
"SELECT folder_name FROM albums WHERE id = ?",
|
|
||||||
[albumId]
|
|
||||||
);
|
|
||||||
if (albums.length === 0) {
|
|
||||||
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderName = albums[0].folder_name;
|
|
||||||
|
|
||||||
// 사진 조회 (멤버 정보 포함)
|
|
||||||
const [photos] = await pool.query(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name,
|
|
||||||
p.sort_order, p.width, p.height, p.file_size,
|
|
||||||
GROUP_CONCAT(pm.member_id) as member_ids
|
|
||||||
FROM album_photos p
|
|
||||||
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
|
|
||||||
WHERE p.album_id = ?
|
|
||||||
GROUP BY p.id
|
|
||||||
ORDER BY p.sort_order ASC
|
|
||||||
`,
|
|
||||||
[albumId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 멤버 배열 파싱
|
|
||||||
const result = photos.map((photo) => ({
|
|
||||||
...photo,
|
|
||||||
members: photo.member_ids ? photo.member_ids.split(",").map(Number) : [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("사진 조회 오류:", error);
|
|
||||||
res.status(500).json({ error: "사진 조회 중 오류가 발생했습니다." });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 앨범 티저 목록 조회
|
|
||||||
router.get("/albums/:albumId/teasers", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { albumId } = req.params;
|
|
||||||
|
|
||||||
// 앨범 존재 확인
|
|
||||||
const [albums] = await pool.query(
|
|
||||||
"SELECT folder_name FROM albums WHERE id = ?",
|
|
||||||
[albumId]
|
|
||||||
);
|
|
||||||
if (albums.length === 0) {
|
|
||||||
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 티저 조회
|
|
||||||
const [teasers] = await pool.query(
|
|
||||||
`SELECT id, original_url, medium_url, thumb_url, sort_order
|
|
||||||
FROM album_teasers
|
|
||||||
WHERE album_id = ?
|
|
||||||
ORDER BY sort_order ASC`,
|
|
||||||
[albumId]
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json(teasers);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("티저 조회 오류:", error);
|
|
||||||
res.status(500).json({ error: "티저 조회 중 오류가 발생했습니다." });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 티저 삭제
|
|
||||||
router.delete(
|
|
||||||
"/albums/:albumId/teasers/:teaserId",
|
|
||||||
authenticateToken,
|
|
||||||
async (req, res) => {
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
const { albumId, teaserId } = req.params;
|
|
||||||
|
|
||||||
// 티저 정보 조회
|
|
||||||
const [teasers] = await connection.query(
|
|
||||||
"SELECT t.*, a.folder_name FROM album_teasers t JOIN albums a ON t.album_id = a.id WHERE t.id = ? AND t.album_id = ?",
|
|
||||||
[teaserId, albumId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (teasers.length === 0) {
|
|
||||||
return res.status(404).json({ error: "티저를 찾을 수 없습니다." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const teaser = teasers[0];
|
|
||||||
const filename = teaser.original_url.split("/").pop();
|
|
||||||
const basePath = `album/${teaser.folder_name}/teaser`;
|
|
||||||
|
|
||||||
// RustFS에서 삭제 (3가지 크기 모두)
|
|
||||||
const sizes = ["original", "medium_800", "thumb_400"];
|
|
||||||
for (const size of sizes) {
|
|
||||||
try {
|
|
||||||
await s3Client.send(
|
|
||||||
new DeleteObjectCommand({
|
|
||||||
Bucket: BUCKET,
|
|
||||||
Key: `${basePath}/${size}/${filename}`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (s3Error) {
|
|
||||||
console.error(`S3 삭제 오류 (${size}):`, s3Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 티저 삭제
|
|
||||||
await connection.query("DELETE FROM album_teasers WHERE id = ?", [
|
|
||||||
teaserId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
|
|
||||||
res.json({ message: "티저가 삭제되었습니다." });
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
console.error("티저 삭제 오류:", error);
|
|
||||||
res.status(500).json({ error: "티저 삭제 중 오류가 발생했습니다." });
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 사진 업로드 (SSE로 실시간 진행률 전송)
|
|
||||||
router.post(
|
|
||||||
"/albums/:albumId/photos",
|
|
||||||
authenticateToken,
|
|
||||||
upload.array("photos", 50),
|
|
||||||
async (req, res) => {
|
|
||||||
// SSE 헤더 설정
|
|
||||||
res.setHeader("Content-Type", "text/event-stream");
|
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
|
||||||
res.setHeader("Connection", "keep-alive");
|
|
||||||
|
|
||||||
const sendProgress = (current, total, message) => {
|
|
||||||
res.write(`data: ${JSON.stringify({ current, total, message })}\n\n`);
|
|
||||||
};
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
const { albumId } = req.params;
|
|
||||||
const metadata = JSON.parse(req.body.metadata || "[]");
|
|
||||||
const startNumber = parseInt(req.body.startNumber) || null;
|
|
||||||
const photoType = req.body.photoType || "concept"; // 'concept' | 'teaser'
|
|
||||||
|
|
||||||
// 앨범 정보 조회
|
|
||||||
const [albums] = await connection.query(
|
|
||||||
"SELECT folder_name FROM albums WHERE id = ?",
|
|
||||||
[albumId]
|
|
||||||
);
|
|
||||||
if (albums.length === 0) {
|
|
||||||
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderName = albums[0].folder_name;
|
|
||||||
|
|
||||||
// 시작 번호 결정 (클라이언트 지정 또는 기존 사진 다음 번호)
|
|
||||||
let nextOrder;
|
|
||||||
if (startNumber && startNumber > 0) {
|
|
||||||
nextOrder = startNumber;
|
|
||||||
} else {
|
|
||||||
const [existingPhotos] = await connection.query(
|
|
||||||
"SELECT MAX(sort_order) as maxOrder FROM album_photos WHERE album_id = ?",
|
|
||||||
[albumId]
|
|
||||||
);
|
|
||||||
nextOrder = (existingPhotos[0].maxOrder || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadedPhotos = [];
|
|
||||||
const totalFiles = req.files.length;
|
|
||||||
|
|
||||||
for (let i = 0; i < req.files.length; i++) {
|
|
||||||
const file = req.files[i];
|
|
||||||
const meta = metadata[i] || {};
|
|
||||||
const orderNum = String(nextOrder + i).padStart(2, "0");
|
|
||||||
const filename = `${orderNum}.webp`;
|
|
||||||
|
|
||||||
// 진행률 전송
|
|
||||||
sendProgress(i + 1, totalFiles, `${filename} 처리 중...`);
|
|
||||||
|
|
||||||
// Sharp로 이미지 처리 (병렬)
|
|
||||||
const [originalBuffer, medium800Buffer, thumb400Buffer] =
|
|
||||||
await Promise.all([
|
|
||||||
sharp(file.buffer).webp({ lossless: true }).toBuffer(),
|
|
||||||
sharp(file.buffer)
|
|
||||||
.resize(800, null, { withoutEnlargement: true })
|
|
||||||
.webp({ quality: 85 })
|
|
||||||
.toBuffer(),
|
|
||||||
sharp(file.buffer)
|
|
||||||
.resize(400, null, { withoutEnlargement: true })
|
|
||||||
.webp({ quality: 80 })
|
|
||||||
.toBuffer(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const originalMeta = await sharp(originalBuffer).metadata();
|
|
||||||
|
|
||||||
// RustFS 업로드 (병렬)
|
|
||||||
// 컨셉 포토: photo/, 티저 이미지: teaser/
|
|
||||||
const subFolder = photoType === "teaser" ? "teaser" : "photo";
|
|
||||||
const basePath = `album/${folderName}/${subFolder}`;
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
s3Client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: BUCKET,
|
|
||||||
Key: `${basePath}/original/${filename}`,
|
|
||||||
Body: originalBuffer,
|
|
||||||
ContentType: "image/webp",
|
|
||||||
})
|
|
||||||
),
|
|
||||||
s3Client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: BUCKET,
|
|
||||||
Key: `${basePath}/medium_800/${filename}`,
|
|
||||||
Body: medium800Buffer,
|
|
||||||
ContentType: "image/webp",
|
|
||||||
})
|
|
||||||
),
|
|
||||||
s3Client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: BUCKET,
|
|
||||||
Key: `${basePath}/thumb_400/${filename}`,
|
|
||||||
Body: thumb400Buffer,
|
|
||||||
ContentType: "image/webp",
|
|
||||||
})
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 3개 해상도별 URL 생성
|
|
||||||
const originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`;
|
|
||||||
const mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${filename}`;
|
|
||||||
const thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${filename}`;
|
|
||||||
|
|
||||||
let photoId;
|
|
||||||
|
|
||||||
// DB 저장 - 티저와 컨셉 포토 분기
|
|
||||||
if (photoType === "teaser") {
|
|
||||||
// 티저 이미지 → album_teasers 테이블
|
|
||||||
const [result] = await connection.query(
|
|
||||||
`INSERT INTO album_teasers
|
|
||||||
(album_id, original_url, medium_url, thumb_url, sort_order)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
[albumId, originalUrl, mediumUrl, thumbUrl, nextOrder + i]
|
|
||||||
);
|
|
||||||
photoId = result.insertId;
|
|
||||||
} else {
|
|
||||||
// 컨셉 포토 → album_photos 테이블
|
|
||||||
const [result] = await connection.query(
|
|
||||||
`INSERT INTO album_photos
|
|
||||||
(album_id, original_url, medium_url, thumb_url, photo_type, concept_name, sort_order, width, height, file_size)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
albumId,
|
|
||||||
originalUrl,
|
|
||||||
mediumUrl,
|
|
||||||
thumbUrl,
|
|
||||||
meta.groupType || "group",
|
|
||||||
meta.conceptName || null,
|
|
||||||
nextOrder + i,
|
|
||||||
originalMeta.width,
|
|
||||||
originalMeta.height,
|
|
||||||
originalBuffer.length,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
photoId = result.insertId;
|
|
||||||
|
|
||||||
// 멤버 태깅 저장 (컨셉 포토만)
|
|
||||||
if (meta.members && meta.members.length > 0) {
|
|
||||||
for (const memberId of meta.members) {
|
|
||||||
await connection.query(
|
|
||||||
"INSERT INTO album_photo_members (photo_id, member_id) VALUES (?, ?)",
|
|
||||||
[photoId, memberId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadedPhotos.push({
|
|
||||||
id: photoId,
|
|
||||||
original_url: originalUrl,
|
|
||||||
medium_url: mediumUrl,
|
|
||||||
thumb_url: thumbUrl,
|
|
||||||
filename,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
|
|
||||||
// 완료 이벤트 전송
|
|
||||||
res.write(
|
|
||||||
`data: ${JSON.stringify({
|
|
||||||
done: true,
|
|
||||||
message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`,
|
|
||||||
photos: uploadedPhotos,
|
|
||||||
})}\n\n`
|
|
||||||
);
|
|
||||||
res.end();
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
console.error("사진 업로드 오류:", error);
|
|
||||||
res.write(
|
|
||||||
`data: ${JSON.stringify({
|
|
||||||
error: "사진 업로드 중 오류가 발생했습니다.",
|
|
||||||
})}\n\n`
|
|
||||||
);
|
|
||||||
res.end();
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 사진 삭제
|
|
||||||
router.delete(
|
|
||||||
"/albums/:albumId/photos/:photoId",
|
|
||||||
authenticateToken,
|
|
||||||
async (req, res) => {
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
const { albumId, photoId } = req.params;
|
|
||||||
|
|
||||||
// 사진 정보 조회
|
|
||||||
const [photos] = await connection.query(
|
|
||||||
"SELECT p.*, a.folder_name FROM album_photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND p.album_id = ?",
|
|
||||||
[photoId, albumId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (photos.length === 0) {
|
|
||||||
return res.status(404).json({ error: "사진을 찾을 수 없습니다." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const photo = photos[0];
|
|
||||||
const filename = photo.original_url.split("/").pop();
|
|
||||||
const basePath = `album/${photo.folder_name}/photo`;
|
|
||||||
|
|
||||||
// RustFS에서 삭제 (3가지 크기 모두)
|
|
||||||
const sizes = ["original", "medium_800", "thumb_400"];
|
|
||||||
for (const size of sizes) {
|
|
||||||
try {
|
|
||||||
await s3Client.send(
|
|
||||||
new DeleteObjectCommand({
|
|
||||||
Bucket: BUCKET,
|
|
||||||
Key: `${basePath}/${size}/${filename}`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (s3Error) {
|
|
||||||
console.error(`S3 삭제 오류 (${size}):`, s3Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 멤버 태깅 삭제
|
|
||||||
await connection.query(
|
|
||||||
"DELETE FROM album_photo_members WHERE photo_id = ?",
|
|
||||||
[photoId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 사진 삭제
|
|
||||||
await connection.query("DELETE FROM album_photos WHERE id = ?", [
|
|
||||||
photoId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,17 @@ async function getAlbumDetails(album) {
|
||||||
);
|
);
|
||||||
album.tracks = tracks;
|
album.tracks = tracks;
|
||||||
|
|
||||||
// 티저 이미지 조회 (3개 해상도 URL 포함)
|
// 티저 이미지 조회
|
||||||
const [teasers] = await pool.query(
|
const [teasers] = await pool.query(
|
||||||
"SELECT original_url, medium_url, thumb_url FROM album_teasers WHERE album_id = ? ORDER BY sort_order",
|
"SELECT image_url FROM album_teasers WHERE album_id = ? ORDER BY sort_order",
|
||||||
[album.id]
|
[album.id]
|
||||||
);
|
);
|
||||||
album.teasers = teasers;
|
album.teasers = teasers.map((t) => t.image_url);
|
||||||
|
|
||||||
// 컨셉 포토 조회 (멤버 정보 + 3개 해상도 URL + 크기 정보 포함)
|
// 컨셉 포토 조회 (멤버 정보 포함)
|
||||||
const [photos] = await pool.query(
|
const [photos] = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order,
|
p.id, p.photo_url, p.photo_type, p.concept_name, p.sort_order,
|
||||||
p.width, p.height,
|
|
||||||
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
|
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
|
||||||
FROM album_photos p
|
FROM album_photos p
|
||||||
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
|
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
|
||||||
|
|
@ -43,11 +42,7 @@ async function getAlbumDetails(album) {
|
||||||
}
|
}
|
||||||
conceptPhotos[concept].push({
|
conceptPhotos[concept].push({
|
||||||
id: photo.id,
|
id: photo.id,
|
||||||
original_url: photo.original_url,
|
url: photo.photo_url,
|
||||||
medium_url: photo.medium_url,
|
|
||||||
thumb_url: photo.thumb_url,
|
|
||||||
width: photo.width,
|
|
||||||
height: photo.height,
|
|
||||||
type: photo.photo_type,
|
type: photo.photo_type,
|
||||||
members: photo.members,
|
members: photo.members,
|
||||||
sortOrder: photo.sort_order,
|
sortOrder: photo.sort_order,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const router = express.Router();
|
||||||
router.get("/", async (req, res) => {
|
router.get("/", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
"SELECT id, name, birth_date, position, image_url, instagram, is_former FROM members ORDER BY is_former, birth_date"
|
"SELECT id, name, name_en, birth_date, position, image_url, instagram FROM members ORDER BY id"
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
services:
|
services:
|
||||||
# 프론트엔드 - Vite 개발 서버
|
# 프론트엔드 - Vite 개발 서버
|
||||||
fromis9-frontend:
|
fromis9-web:
|
||||||
image: node:20-alpine
|
image: node:20-alpine
|
||||||
container_name: fromis9-frontend
|
container_name: fromis9-web
|
||||||
labels:
|
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 80"
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 80"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
services:
|
services:
|
||||||
fromis9-web:
|
fromis9-web:
|
||||||
build: .
|
build: .
|
||||||
container_name: fromis9-frontend
|
container_name: fromis9-web
|
||||||
labels:
|
|
||||||
- "com.centurylinklabs.watchtower.enable=false"
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# fromis_9 Photos 테이블에서 이미지 다운로드 스크립트
|
|
||||||
# 앨범별로 폴더 분류하여 저장
|
|
||||||
|
|
||||||
OUTPUT_DIR="/docker/fromis_9/downloaded_photos"
|
|
||||||
mkdir -p "$OUTPUT_DIR"
|
|
||||||
|
|
||||||
# MariaDB에서 데이터 가져오기
|
|
||||||
docker exec mariadb mariadb -u admin -p'auddnek0403!' fromis_9 -N -e "SELECT photo_id, album_name, photo FROM Photos;" | while IFS=$'\t' read -r photo_id album_name photo_url; do
|
|
||||||
# 앨범명에서 특수문자 제거하여 폴더명 생성
|
|
||||||
folder_name=$(echo "$album_name" | sed 's/[^a-zA-Z0-9가-힣 ]/_/g' | sed 's/ */_/g')
|
|
||||||
|
|
||||||
# 폴더 생성
|
|
||||||
mkdir -p "$OUTPUT_DIR/$folder_name"
|
|
||||||
|
|
||||||
# 파일명 생성 (photo_id 기반)
|
|
||||||
filename="${photo_id}.jpg"
|
|
||||||
filepath="$OUTPUT_DIR/$folder_name/$filename"
|
|
||||||
|
|
||||||
# 이미 다운로드된 파일은 건너뛰기
|
|
||||||
if [ -f "$filepath" ]; then
|
|
||||||
echo "Skip: $filepath (already exists)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 다운로드
|
|
||||||
echo "Downloading: $album_name/$filename"
|
|
||||||
curl -s -L -o "$filepath" "$photo_url"
|
|
||||||
|
|
||||||
# 다운로드 실패 시 삭제
|
|
||||||
if [ ! -s "$filepath" ]; then
|
|
||||||
rm -f "$filepath"
|
|
||||||
echo "Failed: $filepath"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Rate limiting (0.2초 대기)
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Download complete!"
|
|
||||||
echo "Saved to: $OUTPUT_DIR"
|
|
||||||
|
|
||||||
# 결과 요약
|
|
||||||
echo ""
|
|
||||||
echo "=== Summary ==="
|
|
||||||
for dir in "$OUTPUT_DIR"/*/; do
|
|
||||||
if [ -d "$dir" ]; then
|
|
||||||
count=$(ls -1 "$dir" 2>/dev/null | wc -l)
|
|
||||||
dirname=$(basename "$dir")
|
|
||||||
echo "$dirname: $count files"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
@ -14,7 +14,6 @@ import AdminLogin from './pages/pc/admin/AdminLogin';
|
||||||
import AdminDashboard from './pages/pc/admin/AdminDashboard';
|
import AdminDashboard from './pages/pc/admin/AdminDashboard';
|
||||||
import AdminAlbums from './pages/pc/admin/AdminAlbums';
|
import AdminAlbums from './pages/pc/admin/AdminAlbums';
|
||||||
import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm';
|
import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm';
|
||||||
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
|
|
||||||
|
|
||||||
// PC 레이아웃
|
// PC 레이아웃
|
||||||
import PCLayout from './components/pc/Layout';
|
import PCLayout from './components/pc/Layout';
|
||||||
|
|
@ -30,7 +29,6 @@ function App() {
|
||||||
<Route path="/admin/albums" element={<AdminAlbums />} />
|
<Route path="/admin/albums" element={<AdminAlbums />} />
|
||||||
<Route path="/admin/albums/new" element={<AdminAlbumForm />} />
|
<Route path="/admin/albums/new" element={<AdminAlbumForm />} />
|
||||||
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
||||||
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
|
||||||
|
|
||||||
{/* 일반 페이지 (레이아웃 포함) */}
|
{/* 일반 페이지 (레이아웃 포함) */}
|
||||||
<Route path="/*" element={
|
<Route path="/*" element={
|
||||||
|
|
|
||||||
|
|
@ -117,8 +117,8 @@ export const schedules = [
|
||||||
|
|
||||||
// 공식 SNS 링크
|
// 공식 SNS 링크
|
||||||
export const socialLinks = {
|
export const socialLinks = {
|
||||||
youtube: "https://www.youtube.com/@fromis9_official",
|
youtube: "https://www.youtube.com/c/officialfromis9",
|
||||||
instagram: "https://www.instagram.com/officialfromis_9",
|
instagram: "https://www.instagram.com/officialfromis9/",
|
||||||
twitter: "https://twitter.com/realfromis_9",
|
twitter: "https://twitter.com/realfromis_9",
|
||||||
tiktok: "https://www.tiktok.com/@officialfromis_9",
|
tiktok: "https://www.tiktok.com/@officialfromis_9",
|
||||||
fancafe: "https://cafe.daum.net/officialfromis9",
|
fancafe: "https://cafe.daum.net/officialfromis9",
|
||||||
|
|
|
||||||
|
|
@ -37,21 +37,6 @@ function AlbumDetail() {
|
||||||
setLightbox(prev => ({ ...prev, open: false }));
|
setLightbox(prev => ({ ...prev, open: false }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 라이트박스 열릴 때 body 스크롤 숨기기
|
|
||||||
useEffect(() => {
|
|
||||||
if (lightbox.open) {
|
|
||||||
document.documentElement.style.overflow = 'hidden';
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
} else {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
};
|
|
||||||
}, [lightbox.open]);
|
|
||||||
|
|
||||||
// 이미지 다운로드 함수
|
// 이미지 다운로드 함수
|
||||||
const downloadImage = useCallback(async () => {
|
const downloadImage = useCallback(async () => {
|
||||||
const imageUrl = lightbox.images[lightbox.index];
|
const imageUrl = lightbox.images[lightbox.index];
|
||||||
|
|
@ -125,7 +110,18 @@ function AlbumDetail() {
|
||||||
});
|
});
|
||||||
}, [name]);
|
}, [name]);
|
||||||
|
|
||||||
// URL 헬퍼 함수는 더 이상 필요 없음 - API에서 직접 제공
|
// URL을 썸네일/원본 버전으로 변환하는 헬퍼
|
||||||
|
const getThumbUrl = (url) => {
|
||||||
|
const parts = url.split('/');
|
||||||
|
const filename = parts.pop();
|
||||||
|
return [...parts, 'thumb_400', filename].join('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOriginalUrl = (url) => {
|
||||||
|
const parts = url.split('/');
|
||||||
|
const filename = parts.pop();
|
||||||
|
return [...parts, 'original', filename].join('/');
|
||||||
|
};
|
||||||
|
|
||||||
// 날짜 포맷팅
|
// 날짜 포맷팅
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
|
|
@ -252,11 +248,11 @@ function AlbumDetail() {
|
||||||
{album.teasers.map((teaser, index) => (
|
{album.teasers.map((teaser, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setLightbox({ open: true, images: album.teasers.map(t => t.original_url), index })}
|
onClick={() => setLightbox({ open: true, images: album.teasers.map(t => getOriginalUrl(t)), index })}
|
||||||
className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10"
|
className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-200 hover:scale-110 hover:shadow-xl hover:z-10"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={teaser.thumb_url}
|
src={getThumbUrl(teaser)}
|
||||||
alt={`Teaser ${index + 1}`}
|
alt={`Teaser ${index + 1}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
@ -279,12 +275,10 @@ function AlbumDetail() {
|
||||||
className="col-span-1"
|
className="col-span-1"
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-bold mb-4">앨범 소개</h2>
|
<h2 className="text-xl font-bold mb-4">앨범 소개</h2>
|
||||||
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
|
<div className="bg-white rounded-2xl shadow-lg p-6">
|
||||||
<div className="max-h-[460px] overflow-y-auto p-6">
|
<p className="text-gray-600 leading-relaxed text-sm whitespace-pre-line">
|
||||||
<p className="text-gray-600 leading-relaxed text-sm whitespace-pre-line text-justify break-all">
|
{album.description}
|
||||||
{album.description}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -318,7 +312,7 @@ function AlbumDetail() {
|
||||||
<h3 className="font-semibold group-hover:text-primary transition-colors">{track.title}</h3>
|
<h3 className="font-semibold group-hover:text-primary transition-colors">{track.title}</h3>
|
||||||
{track.is_title_track === 1 && (
|
{track.is_title_track === 1 && (
|
||||||
<span className="px-2 py-0.5 bg-primary text-white text-xs font-medium rounded-full">
|
<span className="px-2 py-0.5 bg-primary text-white text-xs font-medium rounded-full">
|
||||||
TITLE
|
타이틀
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -363,11 +357,11 @@ function AlbumDetail() {
|
||||||
{previewPhotos.map((photo, idx) => (
|
{previewPhotos.map((photo, idx) => (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
onClick={() => setLightbox({ open: true, images: [photo.original_url], index: 0 })}
|
onClick={() => setLightbox({ open: true, images: [getOriginalUrl(photo.url)], index: 0 })}
|
||||||
className="aspect-square bg-gray-200 rounded-xl overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:z-10"
|
className="aspect-square bg-gray-200 rounded-xl overflow-hidden cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-xl hover:z-10"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={photo.medium_url}
|
src={getThumbUrl(photo.url)}
|
||||||
alt={`컨셉 포토 ${idx + 1}`}
|
alt={`컨셉 포토 ${idx + 1}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
@ -390,96 +384,90 @@ function AlbumDetail() {
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="fixed inset-0 bg-black/95 z-50 overflow-auto"
|
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
{/* 내부 컨테이너 - min-width, min-height 적용 */}
|
{/* 상단 버튼들 */}
|
||||||
<div className="min-w-[1200px] min-h-[800px] w-full h-full relative flex items-center justify-center">
|
<div className="absolute top-6 right-6 flex gap-3 z-10">
|
||||||
{/* 상단 버튼들 */}
|
{/* 다운로드 버튼 */}
|
||||||
<div className="absolute top-6 right-6 flex gap-3 z-10">
|
<button
|
||||||
{/* 다운로드 버튼 */}
|
className="text-white/70 hover:text-white transition-colors"
|
||||||
<button
|
onClick={(e) => {
|
||||||
className="text-white/70 hover:text-white transition-colors"
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
downloadImage();
|
||||||
e.stopPropagation();
|
}}
|
||||||
downloadImage();
|
>
|
||||||
}}
|
<Download size={28} />
|
||||||
>
|
</button>
|
||||||
<Download size={28} />
|
{/* 닫기 버튼 */}
|
||||||
</button>
|
<button
|
||||||
{/* 닫기 버튼 */}
|
className="text-white/70 hover:text-white transition-colors"
|
||||||
<button
|
onClick={closeLightbox}
|
||||||
className="text-white/70 hover:text-white transition-colors"
|
>
|
||||||
onClick={closeLightbox}
|
<X size={32} />
|
||||||
>
|
</button>
|
||||||
<X size={32} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 이전 버튼 */}
|
|
||||||
{lightbox.images.length > 1 && (
|
|
||||||
<button
|
|
||||||
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
goToPrev();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={48} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 로딩 스피너 */}
|
|
||||||
{!imageLoaded && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 이미지 */}
|
|
||||||
<div className="flex flex-col items-center mx-24">
|
|
||||||
<motion.img
|
|
||||||
key={lightbox.index}
|
|
||||||
src={lightbox.images[lightbox.index]}
|
|
||||||
alt="확대 이미지"
|
|
||||||
className={`max-w-[1100px] max-h-[75vh] object-contain rounded-lg transition-opacity duration-200 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onLoad={() => setImageLoaded(true)}
|
|
||||||
initial={{ x: slideDirection * 100 }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 다음 버튼 */}
|
|
||||||
{lightbox.images.length > 1 && (
|
|
||||||
<button
|
|
||||||
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
goToNext();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronRight size={48} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 인디케이터 */}
|
|
||||||
{lightbox.images.length > 1 && (
|
|
||||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-1.5 overflow-x-auto scrollbar-hide" style={{ maxWidth: '1000px' }}>
|
|
||||||
{lightbox.images.map((_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
className={`w-2 h-2 rounded-full transition-colors flex-shrink-0 ${i === lightbox.index ? 'bg-white' : 'bg-white/40'}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setImageLoaded(false);
|
|
||||||
setLightbox({ ...lightbox, index: i });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 이전 버튼 */}
|
||||||
|
{lightbox.images.length > 1 && (
|
||||||
|
<button
|
||||||
|
className="absolute left-6 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToPrev();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={48} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로딩 스피너 */}
|
||||||
|
{!imageLoaded && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 이미지 */}
|
||||||
|
<motion.img
|
||||||
|
key={lightbox.index}
|
||||||
|
src={lightbox.images[lightbox.index]}
|
||||||
|
alt="확대 이미지"
|
||||||
|
className={`max-w-[90vw] max-h-[90vh] object-contain rounded-lg transition-opacity duration-200 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
initial={{ x: slideDirection * 100 }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 다음 버튼 */}
|
||||||
|
{lightbox.images.length > 1 && (
|
||||||
|
<button
|
||||||
|
className="absolute right-6 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToNext();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight size={48} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 인디케이터 - 이미지 2개 이상일 때만 표시 */}
|
||||||
|
{lightbox.images.length > 1 && (
|
||||||
|
<div className="absolute bottom-6 flex gap-2">
|
||||||
|
{lightbox.images.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`w-2 h-2 rounded-full transition-colors ${i === lightbox.index ? 'bg-white' : 'bg-white/40'}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setLightbox({ ...lightbox, index: i });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
|
||||||
|
|
@ -5,49 +5,6 @@ import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||||
import { RowsPhotoAlbum } from 'react-photo-album';
|
import { RowsPhotoAlbum } from 'react-photo-album';
|
||||||
import 'react-photo-album/rows.css';
|
import 'react-photo-album/rows.css';
|
||||||
|
|
||||||
// CSS로 호버 효과 추가 + overflow 문제 수정 + 로드 애니메이션
|
|
||||||
const galleryStyles = `
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px) scale(0.95);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.react-photo-album {
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
.react-photo-album--row {
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
.react-photo-album--photo {
|
|
||||||
transition: transform 0.3s ease, filter 0.3s ease !important;
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: visible !important;
|
|
||||||
animation: fadeInUp 0.4s ease-out backwards;
|
|
||||||
}
|
|
||||||
.react-photo-album--photo:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
filter: brightness(0.9);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
/* 순차적 로드 애니메이션 */
|
|
||||||
.react-photo-album--photo:nth-child(1) { animation-delay: 0.02s; }
|
|
||||||
.react-photo-album--photo:nth-child(2) { animation-delay: 0.04s; }
|
|
||||||
.react-photo-album--photo:nth-child(3) { animation-delay: 0.06s; }
|
|
||||||
.react-photo-album--photo:nth-child(4) { animation-delay: 0.08s; }
|
|
||||||
.react-photo-album--photo:nth-child(5) { animation-delay: 0.1s; }
|
|
||||||
.react-photo-album--photo:nth-child(6) { animation-delay: 0.12s; }
|
|
||||||
.react-photo-album--photo:nth-child(7) { animation-delay: 0.14s; }
|
|
||||||
.react-photo-album--photo:nth-child(8) { animation-delay: 0.16s; }
|
|
||||||
.react-photo-album--photo:nth-child(9) { animation-delay: 0.18s; }
|
|
||||||
.react-photo-album--photo:nth-child(10) { animation-delay: 0.2s; }
|
|
||||||
.react-photo-album--photo:nth-child(n+11) { animation-delay: 0.22s; }
|
|
||||||
`;
|
|
||||||
|
|
||||||
function AlbumGallery() {
|
function AlbumGallery() {
|
||||||
const { name } = useParams();
|
const { name } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -58,28 +15,57 @@ function AlbumGallery() {
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
const [slideDirection, setSlideDirection] = useState(0);
|
const [slideDirection, setSlideDirection] = useState(0);
|
||||||
|
|
||||||
|
// URL을 썸네일/원본 버전으로 변환하는 헬퍼
|
||||||
|
const getThumbUrl = (url) => {
|
||||||
|
// https://s3.../photo/01.webp → https://s3.../photo/thumb_400/01.webp
|
||||||
|
const parts = url.split('/');
|
||||||
|
const filename = parts.pop();
|
||||||
|
return [...parts, 'thumb_400', filename].join('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOriginalUrl = (url) => {
|
||||||
|
const parts = url.split('/');
|
||||||
|
const filename = parts.pop();
|
||||||
|
return [...parts, 'original', filename].join('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 dimensions 로드
|
||||||
|
const loadImageDimensions = (url) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||||
|
img.onerror = () => resolve({ width: 3, height: 4 }); // 기본 3:4 비율
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/albums/by-name/${name}`)
|
fetch(`/api/albums/by-name/${name}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(async data => {
|
||||||
setAlbum(data);
|
setAlbum(data);
|
||||||
const allPhotos = [];
|
const allPhotos = [];
|
||||||
|
|
||||||
if (data.conceptPhotos && typeof data.conceptPhotos === 'object') {
|
if (data.conceptPhotos && typeof data.conceptPhotos === 'object') {
|
||||||
Object.entries(data.conceptPhotos).forEach(([concept, photos]) => {
|
Object.entries(data.conceptPhotos).forEach(([concept, photos]) => {
|
||||||
photos.forEach(p => allPhotos.push({
|
photos.forEach(p => allPhotos.push({
|
||||||
// API에서 직접 제공하는 URL 및 크기 정보 사용
|
thumbUrl: getThumbUrl(p.url),
|
||||||
mediumUrl: p.medium_url,
|
originalUrl: getOriginalUrl(p.url),
|
||||||
originalUrl: p.original_url,
|
|
||||||
width: p.width || 800,
|
|
||||||
height: p.height || 1200,
|
|
||||||
title: concept,
|
title: concept,
|
||||||
members: p.members ? p.members.split(', ') : []
|
members: p.members ? p.members.split(', ') : []
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setPhotos(allPhotos);
|
// 모든 이미지 dimensions 로드
|
||||||
|
const photosWithDimensions = await Promise.all(
|
||||||
|
allPhotos.map(async (photo) => {
|
||||||
|
const dims = await loadImageDimensions(photo.thumbUrl);
|
||||||
|
return { ...photo, width: dims.width, height: dims.height };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setPhotos(photosWithDimensions);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|
@ -99,21 +85,6 @@ function AlbumGallery() {
|
||||||
setLightbox(prev => ({ ...prev, open: false }));
|
setLightbox(prev => ({ ...prev, open: false }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 라이트박스 열릴 때 body 스크롤 숨기기
|
|
||||||
useEffect(() => {
|
|
||||||
if (lightbox.open) {
|
|
||||||
document.documentElement.style.overflow = 'hidden';
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
} else {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
};
|
|
||||||
}, [lightbox.open]);
|
|
||||||
|
|
||||||
// 이전/다음 이미지
|
// 이전/다음 이미지
|
||||||
const goToPrev = useCallback(() => {
|
const goToPrev = useCallback(() => {
|
||||||
if (photos.length <= 1) return;
|
if (photos.length <= 1) return;
|
||||||
|
|
@ -230,25 +201,22 @@ function AlbumGallery() {
|
||||||
<p className="text-gray-500 mt-1">{photos.length}장의 사진</p>
|
<p className="text-gray-500 mt-1">{photos.length}장의 사진</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CSS 스타일 주입 */}
|
{/* Justified 갤러리 - react-photo-album */}
|
||||||
<style>{galleryStyles}</style>
|
|
||||||
|
|
||||||
{/* Justified 갤러리 - 동적 비율 + 호버 */}
|
|
||||||
<RowsPhotoAlbum
|
<RowsPhotoAlbum
|
||||||
photos={photos.map((photo, idx) => ({
|
photos={photos.map((photo, idx) => ({
|
||||||
src: photo.mediumUrl,
|
src: photo.thumbUrl,
|
||||||
width: photo.width || 800,
|
width: photo.width || 300,
|
||||||
height: photo.height || 1200,
|
height: photo.height || 400,
|
||||||
key: idx.toString()
|
key: idx.toString()
|
||||||
}))}
|
}))}
|
||||||
targetRowHeight={280}
|
targetRowHeight={300}
|
||||||
spacing={16}
|
spacing={8}
|
||||||
rowConstraints={{ singleRowMaxHeight: 400, minPhotos: 1 }}
|
|
||||||
onClick={({ index }) => openLightbox(index)}
|
onClick={({ index }) => openLightbox(index)}
|
||||||
componentsProps={{
|
componentsProps={{
|
||||||
|
container: { style: { cursor: 'pointer' } },
|
||||||
image: {
|
image: {
|
||||||
loading: 'lazy',
|
loading: 'lazy',
|
||||||
style: { borderRadius: '12px' }
|
style: { borderRadius: '8px', transition: 'transform 0.3s' }
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -263,103 +231,80 @@ function AlbumGallery() {
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="fixed inset-0 bg-black/95 z-50 overflow-auto"
|
className="fixed inset-0 bg-black/95 z-50 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
{/* 내부 컨테이너 - min-width, min-height 적용 (화면 줄여도 크기 유지, 스크롤) */}
|
{/* 상단 버튼들 */}
|
||||||
<div className="min-w-[1200px] min-h-[800px] w-full h-full relative flex items-center justify-center">
|
<div className="absolute top-6 right-6 flex gap-3 z-10">
|
||||||
{/* 상단 버튼들 */}
|
<button
|
||||||
<div className="absolute top-6 right-6 flex gap-3 z-10">
|
className="text-white/70 hover:text-white transition-colors"
|
||||||
<button
|
onClick={downloadImage}
|
||||||
className="text-white/70 hover:text-white transition-colors"
|
>
|
||||||
onClick={downloadImage}
|
<Download size={28} />
|
||||||
>
|
</button>
|
||||||
<Download size={28} />
|
<button
|
||||||
</button>
|
className="text-white/70 hover:text-white transition-colors"
|
||||||
<button
|
onClick={closeLightbox}
|
||||||
className="text-white/70 hover:text-white transition-colors"
|
>
|
||||||
onClick={closeLightbox}
|
<X size={32} />
|
||||||
>
|
</button>
|
||||||
<X size={32} />
|
</div>
|
||||||
</button>
|
|
||||||
|
{/* 카운터 */}
|
||||||
|
<div className="absolute top-6 left-6 text-white/70 text-sm z-10">
|
||||||
|
{lightbox.index + 1} / {photos.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이전 버튼 */}
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<button
|
||||||
|
className="absolute left-6 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={goToPrev}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={48} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로딩 스피너 */}
|
||||||
|
{!imageLoaded && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 카운터 */}
|
{/* 이미지 */}
|
||||||
<div className="absolute top-6 left-6 text-white/70 text-sm z-10">
|
<motion.img
|
||||||
{lightbox.index + 1} / {photos.length}
|
key={lightbox.index}
|
||||||
</div>
|
src={photos[lightbox.index]?.originalUrl}
|
||||||
|
alt="확대 이미지"
|
||||||
|
className={`max-w-[90vw] max-h-[85vh] object-contain rounded-lg transition-opacity duration-200 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
initial={{ x: slideDirection * 100 }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 이전 버튼 - margin으로 이미지와 간격 */}
|
{/* 다음 버튼 */}
|
||||||
{photos.length > 1 && (
|
{photos.length > 1 && (
|
||||||
<button
|
<button
|
||||||
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
className="absolute right-6 text-white/70 hover:text-white transition-colors z-10"
|
||||||
onClick={goToPrev}
|
onClick={goToNext}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={48} />
|
<ChevronRight size={48} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 로딩 스피너 */}
|
{/* 하단 점 인디케이터 */}
|
||||||
{!imageLoaded && (
|
<div className="absolute bottom-6 flex gap-1.5 flex-wrap justify-center max-w-[80vw]">
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
{photos.map((_, i) => (
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
|
<button
|
||||||
</div>
|
key={i}
|
||||||
)}
|
className={`w-2 h-2 rounded-full transition-colors ${i === lightbox.index ? 'bg-white' : 'bg-white/40'}`}
|
||||||
|
onClick={() => {
|
||||||
{/* 이미지 + 컨셉 정보 - 양옆 margin으로 화살표와 간격 */}
|
setImageLoaded(false);
|
||||||
<div className="flex flex-col items-center mx-24">
|
setLightbox({ ...lightbox, index: i });
|
||||||
<motion.img
|
}}
|
||||||
key={lightbox.index}
|
|
||||||
src={photos[lightbox.index]?.originalUrl}
|
|
||||||
alt="확대 이미지"
|
|
||||||
className={`max-w-[1100px] max-h-[75vh] object-contain rounded-lg transition-opacity duration-200 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
|
||||||
onLoad={() => setImageLoaded(true)}
|
|
||||||
initial={{ x: slideDirection * 100 }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
||||||
/>
|
/>
|
||||||
{/* 컨셉 정보 - 정보가 있을 때만 표시 */}
|
))}
|
||||||
{imageLoaded && photos[lightbox.index]?.title && (
|
|
||||||
<div className="mt-6 flex flex-col items-center gap-2">
|
|
||||||
<span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base">
|
|
||||||
{photos[lightbox.index]?.title}
|
|
||||||
</span>
|
|
||||||
{/* 멤버가 있고 빈 문자열이 아닐 때만 표시, 쉼표로 분리해서 개별 태그 */}
|
|
||||||
{photos[lightbox.index]?.members && String(photos[lightbox.index]?.members).trim() && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{String(photos[lightbox.index]?.members).split(',').map((member, idx) => (
|
|
||||||
<span key={idx} className="px-3 py-1.5 bg-primary/80 rounded-full text-white text-sm">
|
|
||||||
{member.trim()}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 다음 버튼 - margin으로 이미지와 간격 */}
|
|
||||||
{photos.length > 1 && (
|
|
||||||
<button
|
|
||||||
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
|
||||||
onClick={goToNext}
|
|
||||||
>
|
|
||||||
<ChevronRight size={48} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 하단 점 인디케이터 - 한 줄 고정, 스크롤바 숨김 */}
|
|
||||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-1.5 overflow-x-auto scrollbar-hide" style={{ maxWidth: '1000px' }}>
|
|
||||||
{photos.map((_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
className={`w-2 h-2 rounded-full transition-colors flex-shrink-0 ${i === lightbox.index ? 'bg-white' : 'bg-white/40'}`}
|
|
||||||
onClick={() => {
|
|
||||||
setImageLoaded(false);
|
|
||||||
setLightbox({ ...lightbox, index: i });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,11 @@ function Discography() {
|
||||||
return titleTrack ? titleTrack.title : tracks[0].title;
|
return titleTrack ? titleTrack.title : tracks[0].title;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 앨범 타입별 개수 계산 (album_type_short 우선 사용)
|
// 앨범 타입별 개수 계산
|
||||||
const getAlbumType = (album) => album.album_type_short || album.album_type;
|
|
||||||
const albumStats = {
|
const albumStats = {
|
||||||
정규: albums.filter(a => getAlbumType(a) === '정규').length,
|
정규: albums.filter(a => a.album_type === '정규').length,
|
||||||
미니: albums.filter(a => getAlbumType(a) === '미니').length,
|
미니: albums.filter(a => a.album_type === '미니').length,
|
||||||
싱글: albums.filter(a => getAlbumType(a) === '싱글').length,
|
싱글: albums.filter(a => a.album_type === '싱글').length,
|
||||||
총: albums.length
|
총: albums.length
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -111,8 +110,8 @@ function Discography() {
|
||||||
key={album.id}
|
key={album.id}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.1, duration: 0.3 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
className="group bg-white rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-shadow duration-300 cursor-pointer"
|
className="group bg-white rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 cursor-pointer"
|
||||||
onClick={() => handleAlbumClick(album.title)}
|
onClick={() => handleAlbumClick(album.title)}
|
||||||
>
|
>
|
||||||
{/* 앨범 커버 */}
|
{/* 앨범 커버 */}
|
||||||
|
|
@ -123,7 +122,7 @@ function Discography() {
|
||||||
<img
|
<img
|
||||||
src={album.cover_url}
|
src={album.cover_url}
|
||||||
alt={album.title}
|
alt={album.title}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 will-change-transform"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 호버 오버레이 */}
|
{/* 호버 오버레이 */}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ function Home() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-5 gap-6">
|
<div className="grid grid-cols-5 gap-6">
|
||||||
{members.filter(m => !m.is_former).map((member, index) => (
|
{members.map((member, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,9 @@ function Members() {
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 현재 멤버 그리드 */}
|
{/* 멤버 그리드 */}
|
||||||
<div className="grid grid-cols-5 gap-8">
|
<div className="grid grid-cols-5 gap-8">
|
||||||
{members.filter(m => !m.is_former).map((member, index) => (
|
{members.map((member, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
|
@ -91,17 +91,15 @@ function Members() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 인스타그램 링크 */}
|
{/* 인스타그램 링크 */}
|
||||||
{member.instagram && (
|
<a
|
||||||
<a
|
href={member.instagram}
|
||||||
href={member.instagram}
|
target="_blank"
|
||||||
target="_blank"
|
rel="noopener noreferrer"
|
||||||
rel="noopener noreferrer"
|
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors mt-auto"
|
||||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors mt-auto"
|
>
|
||||||
>
|
<Instagram size={16} />
|
||||||
<Instagram size={16} />
|
<span>Instagram</span>
|
||||||
<span>Instagram</span>
|
</a>
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 호버 효과 - 컬러 바 */}
|
{/* 호버 효과 - 컬러 바 */}
|
||||||
|
|
@ -111,43 +109,6 @@ function Members() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탈퇴 멤버 섹션 - 콤팩트한 가로 리스트 */}
|
|
||||||
{members.filter(m => m.is_former).length > 0 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
className="mt-12"
|
|
||||||
>
|
|
||||||
<h2 className="text-lg font-bold mb-4 text-gray-400">전 멤버</h2>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{members.filter(m => m.is_former).map((member, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={member.id}
|
|
||||||
initial={{ opacity: 0, x: -10 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.6 + index * 0.05 }}
|
|
||||||
className="group flex items-center gap-3 bg-gray-50 rounded-full pr-4 hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
{/* 작은 원형 이미지 */}
|
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-200 overflow-hidden flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src={member.image_url || '/placeholder-member.jpg'}
|
|
||||||
alt={member.name}
|
|
||||||
className="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* 이름과 포지션 */}
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-600 text-sm">{member.name}</p>
|
|
||||||
<p className="text-xs text-gray-400">{member.position || ''}</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 그룹 정보 */}
|
{/* 그룹 정보 */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
|
@ -157,17 +118,17 @@ function Members() {
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-4 gap-8 text-center">
|
<div className="grid grid-cols-4 gap-8 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-4xl font-bold mb-2">2018.01.24</p>
|
<p className="text-4xl font-bold mb-2">{stats.debutYear}</p>
|
||||||
<p className="text-white/70">데뷔일</p>
|
<p className="text-white/70">데뷔 연도</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-4xl font-bold mb-2">D+{(Math.floor((new Date() - new Date('2018-01-24')) / (1000 * 60 * 60 * 24)) + 1).toLocaleString()}</p>
|
<p className="text-4xl font-bold mb-2">{stats.memberCount}</p>
|
||||||
<p className="text-white/70">D+Day</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-4xl font-bold mb-2">{members.filter(m => !m.is_former).length}</p>
|
|
||||||
<p className="text-white/70">멤버 수</p>
|
<p className="text-white/70">멤버 수</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-4xl font-bold mb-2">{stats.albumCount}</p>
|
||||||
|
<p className="text-white/70">앨범 수</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-4xl font-bold mb-2">{stats.fandomName}</p>
|
<p className="text-4xl font-bold mb-2">{stats.fandomName}</p>
|
||||||
<p className="text-white/70">팬덤명</p>
|
<p className="text-white/70">팬덤명</p>
|
||||||
|
|
|
||||||
|
|
@ -424,19 +424,7 @@ function AdminAlbumForm() {
|
||||||
|
|
||||||
const handleInputChange = (e) => {
|
const handleInputChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
// 앨범명 변경 시 RustFS 폴더명 자동 생성
|
|
||||||
if (name === 'title') {
|
|
||||||
const folderName = value
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[\s.]+/g, '-') // 띄어쓰기, 점을 하이픈으로
|
|
||||||
.replace(/[^a-z0-9가-힣-]/g, '') // 특수문자 제거 (영문, 숫자, 한글, 하이픈만 유지)
|
|
||||||
.replace(/-+/g, '-') // 연속 하이픈 하나로
|
|
||||||
.replace(/^-|-$/g, ''); // 앞뒤 하이픈 제거
|
|
||||||
setFormData(prev => ({ ...prev, title: value, folder_name: folderName }));
|
|
||||||
} else {
|
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCoverChange = (e) => {
|
const handleCoverChange = (e) => {
|
||||||
|
|
@ -468,14 +456,8 @@ function AdminAlbumForm() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTrack = (index, field, value) => {
|
const updateTrack = (index, field, value) => {
|
||||||
// 작사/작곡/편곡 필드에서 '|' (전각 세로 막대)를 ', '로 자동 변환
|
|
||||||
let processedValue = value;
|
|
||||||
if (['lyricist', 'composer', 'arranger'].includes(field)) {
|
|
||||||
processedValue = value.replace(/[||]/g, ', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
setTracks(prev => prev.map((track, i) =>
|
setTracks(prev => prev.map((track, i) =>
|
||||||
i === index ? { ...track, [field]: processedValue } : track
|
i === index ? { ...track, [field]: value } : track
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -764,7 +746,7 @@ function AdminAlbumForm() {
|
||||||
name="description"
|
name="description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
rows={8}
|
rows={4}
|
||||||
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 resize-none"
|
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 resize-none"
|
||||||
placeholder="앨범에 대한 설명을 입력하세요..."
|
placeholder="앨범에 대한 설명을 입력하세요..."
|
||||||
/>
|
/>
|
||||||
|
|
@ -848,7 +830,7 @@ function AdminAlbumForm() {
|
||||||
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="0:00"
|
placeholder="3:30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -875,6 +857,7 @@ function AdminAlbumForm() {
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* 작사/작곡/편곡 */}
|
{/* 작사/작곡/편곡 */}
|
||||||
<div className="space-y-3 mt-3">
|
<div className="space-y-3 mt-3">
|
||||||
|
|
@ -929,7 +912,7 @@ function AdminAlbumForm() {
|
||||||
value={track.lyrics || ''}
|
value={track.lyrics || ''}
|
||||||
onChange={(e) => updateTrack(index, 'lyrics', e.target.value)}
|
onChange={(e) => updateTrack(index, 'lyrics', e.target.value)}
|
||||||
rows={12}
|
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-none min-h-[200px]"
|
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="가사를 입력하세요..."
|
placeholder="가사를 입력하세요..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue