Compare commits

...

14 commits

Author SHA1 Message Date
1ae01fb2d7 fix: 사진 삭제 API 수정, 앨범 호버 애니메이션 개선 2026-01-03 01:06:32 +09:00
2bbb6f53e5 fix: 멤버 페이지 통계 - 현재 멤버 수로 변경 2026-01-02 23:36:48 +09:00
1ad5f6a907 feat: 멤버 관리 개선 - 전/현재 멤버 구분, D+Day 표시, UI 정리 2026-01-02 23:35:36 +09:00
3c06a20ea4 feat: 앨범 상세 UI 개선 - 소개 스크롤, 작사/작곡 자동변환, 타입별 개수 표시 2026-01-02 17:04:27 +09:00
d9d1a447c4 fix: 공식 YouTube, Instagram 링크 수정 2026-01-02 13:31:33 +09:00
96e7ae0539 feat: AlbumGallery 이미지 로드 애니메이션 추가 2026-01-02 12:20:08 +09:00
9f7548b4b4 feat: 관리 탭 UI 개선 - 탭 분리, 전체 선택, 삭제 기능, 애니메이션 추가 2026-01-02 12:17:24 +09:00
4d8d18586c style: 갤러리 사진 간격 증가, 앨범 상세 hover 효과 부드럽게 개선 2026-01-02 11:27:19 +09:00
79fb58e2ee feat: 라이트박스 UI 개선 - min-width/height 적용, body 스크롤 숨김, 이미지 크기 증가, 멤버 태그 개별 표시 2026-01-02 11:07:51 +09:00
ab92e3117e feat: 업로드 버튼에 확인 다이얼로그 추가
- 업로드 전 사진 타입, 파일 개수, 파일명 범위 확인
- 실수로 업로드 방지
2026-01-02 10:24:45 +09:00
57fa0e1393 feat: 관리자 페이지 일괄 편집 도구 추가
- 사진 목록 우측에 sticky 일괄 편집 패널 추가
- 번호 범위로 여러 사진에 타입/멤버/컨셉명 일괄 적용
- 표시 번호(startNumber) 기준으로 범위 입력 가능
- 순수 CSS sticky로 성능 최적화
2026-01-02 10:14:27 +09:00
961ca97920 feat: 앨범 사진 다중 해상도 URL 지원 및 갤러리 UI 개선
- album_photos, album_teasers 테이블에 original_url, medium_url, thumb_url 컬럼 추가
- API에서 3가지 해상도 URL 및 width/height 반환
- AlbumDetail: 티저는 thumb_url(400), 컨셉포토는 medium_url(800) 사용
- AlbumGallery: 동적 비율 + CSS hover 효과 추가
- react-photo-album rowConstraints로 마지막 row 표시 문제 개선
2026-01-02 09:38:04 +09:00
fd1807f38c feat: 앨범 사진/티저 업로드 기능 구현
- SSE 기반 실시간 업로드 진행률 표시
- 컨셉 포토/티저 이미지 분리 (photo/ vs teaser/ 폴더)
- album_photos, album_teasers 테이블에 분리 저장
- 3개 해상도별 URL 컬럼 분리 (original_url, medium_url, thumb_url)
- 파일 업로드 시 타입 선택 잠금
- 티저 모드: 순서만 변경 가능, 메타 정보 입력 불필요
- 이미지 처리 병렬화로 성능 개선
- RUSTFS_PUBLIC_URL 환경변수 추가
2026-01-02 00:10:47 +09:00
ee23e5ffa4 feat: 앨범 사진 관리 UI 구현
- AdminAlbumPhotos 컴포넌트 추가 및 라우트 등록
- 드래그앤드롭 파일 업로드 (깜빡임 방지, 중복 파일 감지)
- 파일 순서 변경 (드래그 정렬 + 직접 입력)
- 사진 메타데이터 입력 (타입, 멤버 태깅, 컨셉명)
- 단체/솔로/유닛 선택에 따른 멤버 태깅 로직
- 삭제 확인 다이얼로그
- 페이지 전환 애니메이션
2026-01-01 22:31:38 +09:00
17 changed files with 4735 additions and 258 deletions

1
.env
View file

@ -12,6 +12,7 @@ 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

File diff suppressed because it is too large Load diff

View file

@ -406,4 +406,398 @@ 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;

View file

@ -12,17 +12,18 @@ async function getAlbumDetails(album) {
); );
album.tracks = tracks; album.tracks = tracks;
// 티저 이미지 조회 // 티저 이미지 조회 (3개 해상도 URL 포함)
const [teasers] = await pool.query( const [teasers] = await pool.query(
"SELECT image_url FROM album_teasers WHERE album_id = ? ORDER BY sort_order", "SELECT original_url, medium_url, thumb_url FROM album_teasers WHERE album_id = ? ORDER BY sort_order",
[album.id] [album.id]
); );
album.teasers = teasers.map((t) => t.image_url); album.teasers = teasers;
// 컨셉 포토 조회 (멤버 정보 포함) // 컨셉 포토 조회 (멤버 정보 + 3개 해상도 URL + 크기 정보 포함)
const [photos] = await pool.query( const [photos] = await pool.query(
`SELECT `SELECT
p.id, p.photo_url, p.photo_type, p.concept_name, p.sort_order, p.id, p.original_url, p.medium_url, p.thumb_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
@ -42,7 +43,11 @@ async function getAlbumDetails(album) {
} }
conceptPhotos[concept].push({ conceptPhotos[concept].push({
id: photo.id, id: photo.id,
url: photo.photo_url, original_url: photo.original_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,

View file

@ -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, name_en, birth_date, position, image_url, instagram FROM members ORDER BY id" "SELECT id, name, birth_date, position, image_url, instagram, is_former FROM members ORDER BY is_former, birth_date"
); );
res.json(rows); res.json(rows);
} catch (error) { } catch (error) {

View file

@ -1,8 +1,10 @@
services: services:
# 프론트엔드 - Vite 개발 서버 # 프론트엔드 - Vite 개발 서버
fromis9-web: fromis9-frontend:
image: node:20-alpine image: node:20-alpine
container_name: fromis9-web container_name: fromis9-frontend
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:

View file

@ -1,7 +1,9 @@
services: services:
fromis9-web: fromis9-web:
build: . build: .
container_name: fromis9-web container_name: fromis9-frontend
labels:
- "com.centurylinklabs.watchtower.enable=false"
env_file: env_file:
- .env - .env
networks: networks:

53
download_photos.sh Executable file
View file

@ -0,0 +1,53 @@
#!/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

View file

@ -14,6 +14,7 @@ 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';
@ -29,6 +30,7 @@ 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={

View file

@ -117,8 +117,8 @@ export const schedules = [
// 공식 SNS 링크 // 공식 SNS 링크
export const socialLinks = { export const socialLinks = {
youtube: "https://www.youtube.com/c/officialfromis9", youtube: "https://www.youtube.com/@fromis9_official",
instagram: "https://www.instagram.com/officialfromis9/", instagram: "https://www.instagram.com/officialfromis_9",
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",

View file

@ -37,6 +37,21 @@ 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];
@ -110,18 +125,7 @@ function AlbumDetail() {
}); });
}, [name]); }, [name]);
// URL / // URL - API
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) => {
@ -248,11 +252,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 => getOriginalUrl(t)), index })} onClick={() => setLightbox({ open: true, images: album.teasers.map(t => t.original_url), index })}
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" 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"
> >
<img <img
src={getThumbUrl(teaser)} src={teaser.thumb_url}
alt={`Teaser ${index + 1}`} alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@ -275,10 +279,12 @@ 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 p-6"> <div className="bg-white rounded-2xl shadow-lg overflow-hidden">
<p className="text-gray-600 leading-relaxed text-sm whitespace-pre-line"> <div className="max-h-[460px] overflow-y-auto p-6">
{album.description} <p className="text-gray-600 leading-relaxed text-sm whitespace-pre-line text-justify break-all">
</p> {album.description}
</p>
</div>
</div> </div>
</motion.div> </motion.div>
)} )}
@ -312,7 +318,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>
@ -357,11 +363,11 @@ function AlbumDetail() {
{previewPhotos.map((photo, idx) => ( {previewPhotos.map((photo, idx) => (
<div <div
key={photo.id} key={photo.id}
onClick={() => setLightbox({ open: true, images: [getOriginalUrl(photo.url)], index: 0 })} onClick={() => setLightbox({ open: true, images: [photo.original_url], index: 0 })}
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" 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"
> >
<img <img
src={getThumbUrl(photo.url)} src={photo.medium_url}
alt={`컨셉 포토 ${idx + 1}`} alt={`컨셉 포토 ${idx + 1}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@ -384,90 +390,96 @@ 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/90 z-50 flex items-center justify-center" className="fixed inset-0 bg-black/95 z-50 overflow-auto"
> >
{/* 상단 버튼들 */} {/* 내부 컨테이너 - min-width, min-height 적용 */}
<div className="absolute top-6 right-6 flex gap-3 z-10"> <div className="min-w-[1200px] min-h-[800px] w-full h-full relative flex items-center justify-center">
{/* 다운로드 버튼 */} {/* 상단 버튼들 */}
<button <div className="absolute top-6 right-6 flex gap-3 z-10">
className="text-white/70 hover:text-white transition-colors" {/* 다운로드 버튼 */}
onClick={(e) => { <button
e.stopPropagation(); className="text-white/70 hover:text-white transition-colors"
downloadImage();
}}
>
<Download size={28} />
</button>
{/* 닫기 버튼 */}
<button
className="text-white/70 hover:text-white transition-colors"
onClick={closeLightbox}
>
<X size={32} />
</button>
</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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setLightbox({ ...lightbox, index: i }); downloadImage();
}} }}
>
<Download size={28} />
</button>
{/* 닫기 버튼 */}
<button
className="text-white/70 hover:text-white transition-colors"
onClick={closeLightbox}
>
<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>
)}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>

View file

@ -5,6 +5,49 @@ 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();
@ -15,57 +58,28 @@ 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(async data => { .then(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({
thumbUrl: getThumbUrl(p.url), // API URL
originalUrl: getOriginalUrl(p.url), mediumUrl: p.medium_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(', ') : []
})); }));
}); });
} }
// dimensions setPhotos(allPhotos);
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 => {
@ -85,6 +99,21 @@ 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;
@ -201,22 +230,25 @@ function AlbumGallery() {
<p className="text-gray-500 mt-1">{photos.length}장의 사진</p> <p className="text-gray-500 mt-1">{photos.length}장의 사진</p>
</div> </div>
{/* Justified 갤러리 - react-photo-album */} {/* CSS 스타일 주입 */}
<style>{galleryStyles}</style>
{/* Justified 갤러리 - 동적 비율 + 호버 */}
<RowsPhotoAlbum <RowsPhotoAlbum
photos={photos.map((photo, idx) => ({ photos={photos.map((photo, idx) => ({
src: photo.thumbUrl, src: photo.mediumUrl,
width: photo.width || 300, width: photo.width || 800,
height: photo.height || 400, height: photo.height || 1200,
key: idx.toString() key: idx.toString()
}))} }))}
targetRowHeight={300} targetRowHeight={280}
spacing={8} spacing={16}
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: '8px', transition: 'transform 0.3s' } style: { borderRadius: '12px' }
} }
}} }}
/> />
@ -231,80 +263,103 @@ 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 flex items-center justify-center" className="fixed inset-0 bg-black/95 z-50 overflow-auto"
> >
{/* 상단 버튼들 */} {/* 내부 컨테이너 - min-width, min-height 적용 (화면 줄여도 크기 유지, 스크롤) */}
<div className="absolute top-6 right-6 flex gap-3 z-10"> <div className="min-w-[1200px] min-h-[800px] w-full h-full relative flex items-center justify-center">
<button {/* 상단 버튼들 */}
className="text-white/70 hover:text-white transition-colors" <div className="absolute top-6 right-6 flex gap-3 z-10">
onClick={downloadImage} <button
> className="text-white/70 hover:text-white transition-colors"
<Download size={28} /> onClick={downloadImage}
</button> >
<button <Download size={28} />
className="text-white/70 hover:text-white transition-colors" </button>
onClick={closeLightbox} <button
> className="text-white/70 hover:text-white transition-colors"
<X size={32} /> onClick={closeLightbox}
</button> >
</div> <X size={32} />
</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>
)}
{/* 이미지 */} {/* 카운터 */}
<motion.img <div className="absolute top-6 left-6 text-white/70 text-sm z-10">
key={lightbox.index} {lightbox.index + 1} / {photos.length}
src={photos[lightbox.index]?.originalUrl} </div>
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 right-6 text-white/70 hover:text-white transition-colors z-10" className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
onClick={goToNext} onClick={goToPrev}
> >
<ChevronRight size={48} /> <ChevronLeft size={48} />
</button> </button>
)} )}
{/* 하단 점 인디케이터 */} {/* 로딩 스피너 */}
<div className="absolute bottom-6 flex gap-1.5 flex-wrap justify-center max-w-[80vw]"> {!imageLoaded && (
{photos.map((_, i) => ( <div className="absolute inset-0 flex items-center justify-center">
<button <div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
key={i} </div>
className={`w-2 h-2 rounded-full transition-colors ${i === lightbox.index ? 'bg-white' : 'bg-white/40'}`} )}
onClick={() => {
setImageLoaded(false); {/* 이미지 + 컨셉 정보 - 양옆 margin으로 화살표와 간격 */}
setLightbox({ ...lightbox, index: i }); <div className="flex flex-col items-center mx-24">
}} <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>
)} )}

View file

@ -35,11 +35,12 @@ 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 => 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.filter(a => getAlbumType(a) === '싱글').length,
: albums.length : albums.length
}; };
@ -110,8 +111,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 }} transition={{ delay: index * 0.1, duration: 0.3 }}
className="group bg-white rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 cursor-pointer" className="group bg-white rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-shadow duration-300 cursor-pointer"
onClick={() => handleAlbumClick(album.title)} onClick={() => handleAlbumClick(album.title)}
> >
{/* 앨범 커버 */} {/* 앨범 커버 */}
@ -122,7 +123,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" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 will-change-transform"
/> />
{/* 호버 오버레이 */} {/* 호버 오버레이 */}

View file

@ -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.map((member, index) => ( {members.filter(m => !m.is_former).map((member, index) => (
<motion.div <motion.div
key={member.id} key={member.id}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}

View file

@ -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.map((member, index) => ( {members.filter(m => !m.is_former).map((member, index) => (
<motion.div <motion.div
key={member.id} key={member.id}
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
@ -91,15 +91,17 @@ function Members() {
</div> </div>
{/* 인스타그램 링크 */} {/* 인스타그램 링크 */}
<a {member.instagram && (
href={member.instagram} <a
target="_blank" href={member.instagram}
rel="noopener noreferrer" target="_blank"
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors mt-auto" rel="noopener noreferrer"
> className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors mt-auto"
<Instagram size={16} /> >
<span>Instagram</span> <Instagram size={16} />
</a> <span>Instagram</span>
</a>
)}
</div> </div>
{/* 호버 효과 - 컬러 바 */} {/* 호버 효과 - 컬러 바 */}
@ -109,6 +111,43 @@ 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 }}
@ -118,17 +157,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">{stats.debutYear}</p> <p className="text-4xl font-bold mb-2">2018.01.24</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">{stats.memberCount}</p> <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-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>

View file

@ -424,7 +424,19 @@ 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) => {
@ -456,8 +468,14 @@ 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]: value } : track i === index ? { ...track, [field]: processedValue } : track
)); ));
}; };
@ -746,7 +764,7 @@ function AdminAlbumForm() {
name="description" name="description"
value={formData.description} value={formData.description}
onChange={handleInputChange} onChange={handleInputChange}
rows={4} rows={8}
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="앨범에 대한 설명을 입력하세요..."
/> />
@ -830,7 +848,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="3:30" placeholder="0:00"
/> />
</div> </div>
</div> </div>
@ -857,7 +875,6 @@ 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">
@ -912,7 +929,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-y 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-none min-h-[200px]"
placeholder="가사를 입력하세요..." placeholder="가사를 입력하세요..."
/> />
</div> </div>

File diff suppressed because it is too large Load diff