feat: 멤버 관리 개선 - 전/현재 멤버 구분, D+Day 표시, UI 정리
This commit is contained in:
parent
3c06a20ea4
commit
1ad5f6a907
7 changed files with 205 additions and 43 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
53
download_photos.sh
Executable file
53
download_photos.sh
Executable 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
|
||||||
|
|
@ -318,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>
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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,6 +91,7 @@ function Members() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 인스타그램 링크 */}
|
{/* 인스타그램 링크 */}
|
||||||
|
{member.instagram && (
|
||||||
<a
|
<a
|
||||||
href={member.instagram}
|
href={member.instagram}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -100,6 +101,7 @@ function Members() {
|
||||||
<Instagram size={16} />
|
<Instagram size={16} />
|
||||||
<span>Instagram</span>
|
<span>Instagram</span>
|
||||||
</a>
|
</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>
|
||||||
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-4xl font-bold mb-2">{stats.memberCount}</p>
|
<p className="text-4xl font-bold mb-2">{stats.memberCount}</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,7 +424,19 @@ function AdminAlbumForm() {
|
||||||
|
|
||||||
const handleInputChange = (e) => {
|
const handleInputChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
||||||
|
// 앨범명 변경 시 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 }));
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCoverChange = (e) => {
|
const handleCoverChange = (e) => {
|
||||||
|
|
|
||||||
|
|
@ -952,10 +952,16 @@ function AdminAlbumPhotos() {
|
||||||
key={`order-${file.id}-${index}-${startNumber}`}
|
key={`order-${file.id}-${index}-${startNumber}`}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const val = e.target.value.trim();
|
const val = e.target.value.trim();
|
||||||
|
// 스크롤 위치 저장
|
||||||
|
const scrollY = window.scrollY;
|
||||||
if (val && !isNaN(val)) {
|
if (val && !isNaN(val)) {
|
||||||
moveToPosition(file.id, val);
|
moveToPosition(file.id, val);
|
||||||
}
|
}
|
||||||
e.target.value = String(pendingFiles.findIndex(f => f.id === file.id) + 1).padStart(2, '0');
|
e.target.value = String(pendingFiles.findIndex(f => f.id === file.id) + 1).padStart(2, '0');
|
||||||
|
// 스크롤 위치 복원
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo(0, scrollY);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
|
|
@ -1010,12 +1016,15 @@ function AdminAlbumPhotos() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 멤버 태깅 (단체는 비활성화) */}
|
{/* 멤버 태깅 (단체는 비활성화) */}
|
||||||
<div className="flex items-center gap-2 flex-wrap min-h-8">
|
<div className="flex flex-col gap-2 min-h-8">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm text-gray-500 w-16">멤버:</span>
|
<span className="text-sm text-gray-500 w-16">멤버:</span>
|
||||||
{file.groupType === 'group' ? (
|
{file.groupType === 'group' ? (
|
||||||
<span className="text-sm text-gray-400">단체 사진은 멤버 태깅이 필요 없습니다</span>
|
<span className="text-sm text-gray-400">단체 사진은 멤버 태깅이 필요 없습니다</span>
|
||||||
) : (
|
) : (
|
||||||
members.map(member => (
|
<>
|
||||||
|
{/* 현재 멤버 */}
|
||||||
|
{members.filter(m => !m.is_former).map(member => (
|
||||||
<button
|
<button
|
||||||
key={member.id}
|
key={member.id}
|
||||||
onClick={() => toggleMember(file.id, member.id)}
|
onClick={() => toggleMember(file.id, member.id)}
|
||||||
|
|
@ -1027,12 +1036,33 @@ function AdminAlbumPhotos() {
|
||||||
>
|
>
|
||||||
{member.name}
|
{member.name}
|
||||||
</button>
|
</button>
|
||||||
))
|
))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{file.groupType === 'solo' && (
|
{file.groupType === 'solo' && (
|
||||||
<span className="text-xs text-gray-400 ml-2">(한 명만 선택)</span>
|
<span className="text-xs text-gray-400 ml-2">(한 명만 선택)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 전 멤버 (다음 줄) */}
|
||||||
|
{file.groupType !== 'group' && members.filter(m => m.is_former).length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm text-gray-400 w-16"></span>
|
||||||
|
{members.filter(m => m.is_former).map(member => (
|
||||||
|
<button
|
||||||
|
key={member.id}
|
||||||
|
onClick={() => toggleMember(file.id, member.id)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
file.members.includes(member.id)
|
||||||
|
? 'bg-gray-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-400 hover:bg-gray-200 border border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{member.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 컨셉/티저 이름 (개별 입력) */}
|
{/* 컨셉/티저 이름 (개별 입력) */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1124,7 +1154,8 @@ function AdminAlbumPhotos() {
|
||||||
멤버 {bulkEdit.groupType === 'solo' ? '(1명)' : '(다중 선택)'}
|
멤버 {bulkEdit.groupType === 'solo' ? '(1명)' : '(다중 선택)'}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{members.map(member => (
|
{/* 현재 멤버 */}
|
||||||
|
{members.filter(m => !m.is_former).map(member => (
|
||||||
<button
|
<button
|
||||||
key={member.id}
|
key={member.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -1146,6 +1177,33 @@ function AdminAlbumPhotos() {
|
||||||
{member.name}
|
{member.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{/* 구분선 */}
|
||||||
|
{members.filter(m => m.is_former).length > 0 && (
|
||||||
|
<span className="text-gray-300 mx-1">|</span>
|
||||||
|
)}
|
||||||
|
{/* 탈퇴 멤버 */}
|
||||||
|
{members.filter(m => m.is_former).map(member => (
|
||||||
|
<button
|
||||||
|
key={member.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (bulkEdit.groupType === 'solo') {
|
||||||
|
setBulkEdit(prev => ({
|
||||||
|
...prev,
|
||||||
|
members: prev.members.includes(member.id) ? [] : [member.id]
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
toggleBulkMember(member.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
bulkEdit.members.includes(member.id)
|
||||||
|
? 'bg-gray-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-400 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{member.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue