feat: 멤버 관리 개선 - 전/현재 멤버 구분, D+Day 표시, UI 정리

This commit is contained in:
caadiq 2026-01-02 23:35:36 +09:00
parent 3c06a20ea4
commit 1ad5f6a907
7 changed files with 205 additions and 43 deletions

View file

@ -7,7 +7,7 @@ const router = express.Router();
router.get("/", async (req, res) => {
try {
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);
} catch (error) {

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

@ -318,7 +318,7 @@ function AlbumDetail() {
<h3 className="font-semibold group-hover:text-primary transition-colors">{track.title}</h3>
{track.is_title_track === 1 && (
<span className="px-2 py-0.5 bg-primary text-white text-xs font-medium rounded-full">
타이틀
TITLE
</span>
)}
</div>

View file

@ -92,7 +92,7 @@ function Home() {
</Link>
</div>
<div className="grid grid-cols-5 gap-6">
{members.map((member, index) => (
{members.filter(m => !m.is_former).map((member, index) => (
<motion.div
key={member.id}
initial={{ opacity: 0, y: 20 }}

View file

@ -60,9 +60,9 @@ function Members() {
</motion.p>
</div>
{/* 멤버 그리드 */}
{/* 현재 멤버 그리드 */}
<div className="grid grid-cols-5 gap-8">
{members.map((member, index) => (
{members.filter(m => !m.is_former).map((member, index) => (
<motion.div
key={member.id}
initial={{ opacity: 0, y: 30 }}
@ -91,6 +91,7 @@ function Members() {
</div>
{/* 인스타그램 링크 */}
{member.instagram && (
<a
href={member.instagram}
target="_blank"
@ -100,6 +101,7 @@ function Members() {
<Instagram size={16} />
<span>Instagram</span>
</a>
)}
</div>
{/* 호버 효과 - 컬러 바 */}
@ -109,6 +111,43 @@ function Members() {
))}
</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
initial={{ opacity: 0, y: 30 }}
@ -118,17 +157,17 @@ function Members() {
>
<div className="grid grid-cols-4 gap-8 text-center">
<div>
<p className="text-4xl font-bold mb-2">{stats.debutYear}</p>
<p className="text-white/70">데뷔 연도</p>
<p className="text-4xl font-bold mb-2">2018.01.24</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>
<p className="text-4xl font-bold mb-2">{stats.memberCount}</p>
<p className="text-white/70">멤버 </p>
</div>
<div>
<p className="text-4xl font-bold mb-2">{stats.albumCount}</p>
<p className="text-white/70">앨범 </p>
</div>
<div>
<p className="text-4xl font-bold mb-2">{stats.fandomName}</p>
<p className="text-white/70">팬덤명</p>

View file

@ -424,7 +424,19 @@ function AdminAlbumForm() {
const handleInputChange = (e) => {
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 }));
}
};
const handleCoverChange = (e) => {

View file

@ -952,10 +952,16 @@ function AdminAlbumPhotos() {
key={`order-${file.id}-${index}-${startNumber}`}
onBlur={(e) => {
const val = e.target.value.trim();
//
const scrollY = window.scrollY;
if (val && !isNaN(val)) {
moveToPosition(file.id, val);
}
e.target.value = String(pendingFiles.findIndex(f => f.id === file.id) + 1).padStart(2, '0');
//
requestAnimationFrame(() => {
window.scrollTo(0, scrollY);
});
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
@ -1010,12 +1016,15 @@ function AdminAlbumPhotos() {
</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>
{file.groupType === 'group' ? (
<span className="text-sm text-gray-400">단체 사진은 멤버 태깅이 필요 없습니다</span>
) : (
members.map(member => (
<>
{/* 현재 멤버 */}
{members.filter(m => !m.is_former).map(member => (
<button
key={member.id}
onClick={() => toggleMember(file.id, member.id)}
@ -1027,12 +1036,33 @@ function AdminAlbumPhotos() {
>
{member.name}
</button>
))
))}
</>
)}
{file.groupType === 'solo' && (
<span className="text-xs text-gray-400 ml-2">( 명만 선택)</span>
)}
</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">
@ -1124,7 +1154,8 @@ function AdminAlbumPhotos() {
멤버 {bulkEdit.groupType === 'solo' ? '(1명)' : '(다중 선택)'}
</label>
<div className="flex flex-wrap gap-1.5">
{members.map(member => (
{/* 현재 멤버 */}
{members.filter(m => !m.is_former).map(member => (
<button
key={member.id}
onClick={() => {
@ -1146,6 +1177,33 @@ function AdminAlbumPhotos() {
{member.name}
</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>
)}