멤버 관리 API 구현 및 프론트엔드 연동
Backend: - 멤버 CRUD API 추가 (routes/admin/members.js) - 이미지 업로드 서비스 추가 (services/image.js) - S3에 3가지 해상도로 저장 (original, medium_800, thumb_400) - multipart 플러그인 등록 Frontend: - useAdminAuth 커스텀 훅 추가 (토큰 검증 API 사용) - AdminMembers, AdminMemberEdit useQuery로 변경 - 로그인 확인 로직 중복 제거 - 수정 완료 시 목록 페이지로 이동 및 토스트 표시 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1cb430907f
commit
d44537d870
11 changed files with 2725 additions and 106 deletions
2250
backend/package-lock.json
generated
2250
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,13 +7,16 @@
|
|||
"dev": "node --watch src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.970.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"fastify": "^5.2.1",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"mysql2": "^3.12.0",
|
||||
"node-cron": "^3.0.3"
|
||||
"node-cron": "^3.0.3",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Fastify from 'fastify';
|
||||
import multipart from '@fastify/multipart';
|
||||
import config from './config/index.js';
|
||||
|
||||
// 플러그인
|
||||
|
|
@ -23,6 +24,13 @@ export async function buildApp(opts = {}) {
|
|||
// config 데코레이터 등록
|
||||
fastify.decorate('config', config);
|
||||
|
||||
// multipart 플러그인 등록 (파일 업로드용)
|
||||
await fastify.register(multipart, {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB
|
||||
},
|
||||
});
|
||||
|
||||
// 플러그인 등록 (순서 중요)
|
||||
await fastify.register(dbPlugin);
|
||||
await fastify.register(redisPlugin);
|
||||
|
|
|
|||
|
|
@ -23,4 +23,11 @@ export default {
|
|||
secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026',
|
||||
expiresIn: '30d',
|
||||
},
|
||||
s3: {
|
||||
endpoint: process.env.RUSTFS_ENDPOINT,
|
||||
accessKey: process.env.RUSTFS_ACCESS_KEY,
|
||||
secretKey: process.env.RUSTFS_SECRET_KEY,
|
||||
bucket: process.env.RUSTFS_BUCKET || 'fromis-9',
|
||||
publicUrl: process.env.RUSTFS_PUBLIC_URL,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ async function authPlugin(fastify, opts) {
|
|||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.status(401).send({ error: '인증이 필요합니다.' });
|
||||
return reply.status(401).send({ error: '인증이 필요합니다.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import authRoutes from './auth.js';
|
||||
import membersRoutes from './members.js';
|
||||
|
||||
/**
|
||||
* 어드민 라우트 통합
|
||||
|
|
@ -6,4 +7,7 @@ import authRoutes from './auth.js';
|
|||
export default async function adminRoutes(fastify, opts) {
|
||||
// 인증 라우트 (prefix 없음)
|
||||
fastify.register(authRoutes);
|
||||
|
||||
// 멤버 관리 라우트
|
||||
fastify.register(membersRoutes, { prefix: '/members' });
|
||||
}
|
||||
|
|
|
|||
197
backend/src/routes/admin/members.js
Normal file
197
backend/src/routes/admin/members.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { uploadMemberImage } from '../../services/image.js';
|
||||
|
||||
/**
|
||||
* 어드민 멤버 관리 라우트
|
||||
*/
|
||||
export default async function adminMembersRoutes(fastify, opts) {
|
||||
/**
|
||||
* GET /api/admin/members
|
||||
* 멤버 목록 조회
|
||||
*/
|
||||
fastify.get('/', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const [members] = await fastify.db.query(`
|
||||
SELECT
|
||||
m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former,
|
||||
i.original_url as image_original,
|
||||
i.medium_url as image_medium,
|
||||
i.thumb_url as image_thumb
|
||||
FROM members m
|
||||
LEFT JOIN images i ON m.image_id = i.id
|
||||
ORDER BY m.is_former ASC, m.id ASC
|
||||
`);
|
||||
|
||||
// 별명 조회
|
||||
const [nicknames] = await fastify.db.query(
|
||||
'SELECT member_id, nickname FROM member_nicknames'
|
||||
);
|
||||
|
||||
// 멤버별 별명 매핑
|
||||
const nicknameMap = {};
|
||||
for (const n of nicknames) {
|
||||
if (!nicknameMap[n.member_id]) {
|
||||
nicknameMap[n.member_id] = [];
|
||||
}
|
||||
nicknameMap[n.member_id].push(n.nickname);
|
||||
}
|
||||
|
||||
// 멤버 데이터에 별명 추가
|
||||
const result = members.map(m => ({
|
||||
...m,
|
||||
nicknames: nicknameMap[m.id] || [],
|
||||
image_url: m.image_thumb || m.image_medium || m.image_original,
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '멤버 목록 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/members/:name
|
||||
* 멤버 상세 조회
|
||||
*/
|
||||
fastify.get('/:name', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { name } = request.params;
|
||||
|
||||
try {
|
||||
const [members] = await fastify.db.query(`
|
||||
SELECT
|
||||
m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former,
|
||||
i.original_url as image_original,
|
||||
i.medium_url as image_medium,
|
||||
i.thumb_url as image_thumb
|
||||
FROM members m
|
||||
LEFT JOIN images i ON m.image_id = i.id
|
||||
WHERE m.name = ?
|
||||
`, [decodeURIComponent(name)]);
|
||||
|
||||
if (members.length === 0) {
|
||||
return reply.status(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const member = members[0];
|
||||
|
||||
// 별명 조회
|
||||
const [nicknames] = await fastify.db.query(
|
||||
'SELECT nickname FROM member_nicknames WHERE member_id = ?',
|
||||
[member.id]
|
||||
);
|
||||
|
||||
return {
|
||||
...member,
|
||||
nicknames: nicknames.map(n => n.nickname),
|
||||
image_url: member.image_original || member.image_medium || member.image_thumb,
|
||||
};
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '멤버 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/admin/members/:name
|
||||
* 멤버 수정
|
||||
*/
|
||||
fastify.put('/:name', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { name } = request.params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
try {
|
||||
// 기존 멤버 조회
|
||||
const [existing] = await fastify.db.query(
|
||||
'SELECT id, image_id FROM members WHERE name = ?',
|
||||
[decodedName]
|
||||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return reply.status(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const memberId = existing[0].id;
|
||||
let imageId = existing[0].image_id;
|
||||
|
||||
// multipart 데이터 파싱
|
||||
const parts = request.parts();
|
||||
const fields = {};
|
||||
let imageBuffer = null;
|
||||
|
||||
for await (const part of parts) {
|
||||
if (part.type === 'file' && part.fieldname === 'image') {
|
||||
// 이미지 파일
|
||||
const chunks = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
imageBuffer = Buffer.concat(chunks);
|
||||
} else if (part.type === 'field') {
|
||||
// 일반 필드
|
||||
fields[part.fieldname] = part.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 새 이미지가 있으면 업로드
|
||||
if (imageBuffer && imageBuffer.length > 0) {
|
||||
const newName = fields.name || decodedName;
|
||||
const { originalUrl, mediumUrl, thumbUrl } = await uploadMemberImage(newName, imageBuffer);
|
||||
|
||||
// images 테이블에 저장
|
||||
const [result] = await fastify.db.query(
|
||||
'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)',
|
||||
[originalUrl, mediumUrl, thumbUrl]
|
||||
);
|
||||
imageId = result.insertId;
|
||||
}
|
||||
|
||||
// 멤버 정보 업데이트
|
||||
await fastify.db.query(`
|
||||
UPDATE members SET
|
||||
name = ?,
|
||||
name_en = ?,
|
||||
birth_date = ?,
|
||||
instagram = ?,
|
||||
image_id = ?,
|
||||
is_former = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
fields.name || decodedName,
|
||||
fields.name_en || null,
|
||||
fields.birth_date || null,
|
||||
fields.instagram || null,
|
||||
imageId,
|
||||
fields.is_former === 'true' || fields.is_former === '1' ? 1 : 0,
|
||||
memberId,
|
||||
]);
|
||||
|
||||
// 별명 업데이트 (기존 삭제 후 새로 추가)
|
||||
if (fields.nicknames) {
|
||||
await fastify.db.query(
|
||||
'DELETE FROM member_nicknames WHERE member_id = ?',
|
||||
[memberId]
|
||||
);
|
||||
|
||||
const nicknames = JSON.parse(fields.nicknames);
|
||||
if (nicknames.length > 0) {
|
||||
const values = nicknames.map(n => [memberId, n]);
|
||||
await fastify.db.query(
|
||||
'INSERT INTO member_nicknames (member_id, nickname) VALUES ?',
|
||||
[values]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { message: '멤버 정보가 수정되었습니다', id: memberId };
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.status(500).send({ error: '멤버 수정 실패: ' + err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
99
backend/src/services/image.js
Normal file
99
backend/src/services/image.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import sharp from 'sharp';
|
||||
import config from '../config/index.js';
|
||||
|
||||
// S3 클라이언트 생성
|
||||
const s3Client = new S3Client({
|
||||
endpoint: config.s3.endpoint,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: config.s3.accessKey,
|
||||
secretAccessKey: config.s3.secretKey,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
const BUCKET = config.s3.bucket;
|
||||
const PUBLIC_URL = config.s3.publicUrl;
|
||||
|
||||
/**
|
||||
* 이미지를 3가지 해상도로 변환
|
||||
*/
|
||||
async function processImage(buffer) {
|
||||
const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([
|
||||
sharp(buffer).webp({ lossless: true }).toBuffer(),
|
||||
sharp(buffer)
|
||||
.resize(800, null, { withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.toBuffer(),
|
||||
sharp(buffer)
|
||||
.resize(400, null, { withoutEnlargement: true })
|
||||
.webp({ quality: 80 })
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
return { originalBuffer, mediumBuffer, thumbBuffer };
|
||||
}
|
||||
|
||||
/**
|
||||
* S3에 이미지 업로드
|
||||
*/
|
||||
async function uploadToS3(key, buffer, contentType = 'image/webp') {
|
||||
await s3Client.send(new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
}));
|
||||
return `${PUBLIC_URL}/${BUCKET}/${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* S3에서 이미지 삭제
|
||||
*/
|
||||
async function deleteFromS3(key) {
|
||||
try {
|
||||
await s3Client.send(new DeleteObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`S3 삭제 오류 (${key}):`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 프로필 이미지 업로드
|
||||
* @param {string} name - 멤버 이름
|
||||
* @param {Buffer} buffer - 이미지 버퍼
|
||||
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>}
|
||||
*/
|
||||
export async function uploadMemberImage(name, buffer) {
|
||||
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
|
||||
|
||||
const basePath = `member/${name}`;
|
||||
const filename = `${name}.webp`;
|
||||
|
||||
// 병렬 업로드
|
||||
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
|
||||
uploadToS3(`${basePath}/original/${filename}`, originalBuffer),
|
||||
uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer),
|
||||
uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer),
|
||||
]);
|
||||
|
||||
return { originalUrl, mediumUrl, thumbUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 프로필 이미지 삭제
|
||||
* @param {string} name - 멤버 이름
|
||||
*/
|
||||
export async function deleteMemberImage(name) {
|
||||
const basePath = `member/${name}`;
|
||||
const filename = `${name}.webp`;
|
||||
const sizes = ['original', 'medium_800', 'thumb_400'];
|
||||
|
||||
await Promise.all(
|
||||
sizes.map(size => deleteFromS3(`${basePath}/${size}/${filename}`))
|
||||
);
|
||||
}
|
||||
39
frontend/src/hooks/useAdminAuth.js
Normal file
39
frontend/src/hooks/useAdminAuth.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchAdminApi } from '../api';
|
||||
|
||||
/**
|
||||
* 어드민 인증 상태 관리 훅
|
||||
* - 토큰 유효성 검증
|
||||
* - 미인증 시 로그인 페이지로 리다이렉트
|
||||
*/
|
||||
function useAdminAuth() {
|
||||
const navigate = useNavigate();
|
||||
const token = localStorage.getItem('adminToken');
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['admin', 'auth'],
|
||||
queryFn: () => fetchAdminApi('/api/admin/verify'),
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5, // 5분간 캐시
|
||||
});
|
||||
|
||||
// 토큰 없거나 검증 실패 시 로그인 페이지로 이동
|
||||
useEffect(() => {
|
||||
if (!token || isError) {
|
||||
localStorage.removeItem('adminToken');
|
||||
localStorage.removeItem('adminUser');
|
||||
navigate('/admin');
|
||||
}
|
||||
}, [token, isError, navigate]);
|
||||
|
||||
return {
|
||||
user: data?.user || null,
|
||||
isLoading: !token ? false : isLoading,
|
||||
isAuthenticated: !!data?.valid,
|
||||
};
|
||||
}
|
||||
|
||||
export default useAdminAuth;
|
||||
|
|
@ -1,39 +1,26 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Save, Upload, X,
|
||||
Home, ChevronRight, User, Instagram, Calendar, Tag
|
||||
} from 'lucide-react';
|
||||
import { Save, Upload, X, Home, ChevronRight, User, Instagram, Calendar, Tag } from 'lucide-react';
|
||||
import Toast from '../../../components/Toast';
|
||||
import CustomDatePicker from '../../../components/admin/CustomDatePicker';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
|
||||
// 임시 더미 데이터 (API 구현 전까지 사용)
|
||||
const DUMMY_MEMBERS = {
|
||||
'이새롬': { id: 1, name: '이새롬', name_en: 'SAEROM', birth_date: '1997-01-07', instagram: 'https://www.instagram.com/saeromssee', is_former: true, image_url: null, nicknames: ['새롬', '큰새롬'] },
|
||||
'송하영': { id: 2, name: '송하영', name_en: 'HAYOUNG', birth_date: '1997-09-29', instagram: 'https://www.instagram.com/shy9_29/', is_former: false, image_url: null, nicknames: ['하영', '햄', '햐미'] },
|
||||
'장규리': { id: 3, name: '장규리', name_en: 'GYURI', birth_date: '1997-12-27', instagram: 'https://www.instagram.com/gyurious_j', is_former: true, image_url: null, nicknames: ['규리', '뀨리'] },
|
||||
'박지원': { id: 4, name: '박지원', name_en: 'JIWON', birth_date: '1998-03-20', instagram: 'https://www.instagram.com/xjiwonparkx/', is_former: false, image_url: null, nicknames: ['지원', '쭈니', '지워니'] },
|
||||
'노지선': { id: 5, name: '노지선', name_en: 'JISUN', birth_date: '1998-11-23', instagram: 'https://www.instagram.com/rosieline_', is_former: true, image_url: null, nicknames: ['지선'] },
|
||||
'이서연': { id: 6, name: '이서연', name_en: 'SEOYEON', birth_date: '2000-01-22', instagram: 'https://www.instagram.com/im_theyeon', is_former: true, image_url: null, nicknames: ['서연', '써연'] },
|
||||
'이채영': { id: 7, name: '이채영', name_en: 'CHAEYOUNG', birth_date: '2000-05-14', instagram: 'https://www.instagram.com/chaengrang_/', is_former: false, image_url: null, nicknames: ['채영', '채령'] },
|
||||
'이나경': { id: 8, name: '이나경', name_en: 'NAKYUNG', birth_date: '2000-06-01', instagram: 'https://www.instagram.com/blossomlng_0/', is_former: false, image_url: null, nicknames: ['나경', '낭이', '나낭'] },
|
||||
'백지헌': { id: 9, name: '백지헌', name_en: 'JIHEON', birth_date: '2003-04-17', instagram: 'https://www.instagram.com/jiheonnibaek/', is_former: false, image_url: null, nicknames: ['지헌', '지허니'] },
|
||||
};
|
||||
import { fetchAdminApi, fetchAdminFormData } from '../../../api';
|
||||
|
||||
function AdminMemberEdit() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { name } = useParams();
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const { toast, setToast } = useToast();
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [imagePreview, setImagePreview] = useState(null);
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
const [nicknameInput, setNicknameInput] = useState('');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
name_en: '',
|
||||
|
|
@ -43,33 +30,37 @@ function AdminMemberEdit() {
|
|||
nicknames: []
|
||||
});
|
||||
|
||||
// 멤버 상세 조회
|
||||
const { data: memberData, isLoading: loading, isError } = useQuery({
|
||||
queryKey: ['admin', 'member', name],
|
||||
queryFn: () => fetchAdminApi(`/api/admin/members/${encodeURIComponent(name)}`),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// 데이터 로드 시 폼에 반영
|
||||
useEffect(() => {
|
||||
// 로그인 확인
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const userData = localStorage.getItem('adminUser');
|
||||
|
||||
if (!token || !userData) {
|
||||
navigate('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(JSON.parse(userData));
|
||||
|
||||
// TODO: API 구현 후 fetchMember()로 교체
|
||||
const memberData = DUMMY_MEMBERS[decodeURIComponent(name)];
|
||||
if (memberData) {
|
||||
const birthDate = memberData.birth_date
|
||||
? memberData.birth_date.split('T')[0]
|
||||
: '';
|
||||
setFormData({
|
||||
name: memberData.name || '',
|
||||
name_en: memberData.name_en || '',
|
||||
birth_date: memberData.birth_date || '',
|
||||
birth_date: birthDate,
|
||||
instagram: memberData.instagram || '',
|
||||
is_former: !!memberData.is_former,
|
||||
nicknames: memberData.nicknames || []
|
||||
});
|
||||
setImagePreview(memberData.image_url);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [navigate, name]);
|
||||
}, [memberData]);
|
||||
|
||||
// 에러 처리
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
setToast({ message: '멤버 정보를 불러오는데 실패했습니다.', type: 'error' });
|
||||
}
|
||||
}, [isError, setToast]);
|
||||
|
||||
const handleImageChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
|
|
@ -113,13 +104,33 @@ function AdminMemberEdit() {
|
|||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
// TODO: API 구현 후 실제 저장 로직 추가
|
||||
console.log('저장할 데이터:', formData, imageFile);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('name', formData.name);
|
||||
form.append('name_en', formData.name_en);
|
||||
form.append('birth_date', formData.birth_date);
|
||||
form.append('instagram', formData.instagram);
|
||||
form.append('is_former', formData.is_former ? '1' : '0');
|
||||
form.append('nicknames', JSON.stringify(formData.nicknames));
|
||||
|
||||
setTimeout(() => {
|
||||
setToast({ message: '멤버 정보가 수정되었습니다. (API 미구현)', type: 'success' });
|
||||
if (imageFile) {
|
||||
form.append('image', imageFile);
|
||||
}
|
||||
|
||||
await fetchAdminFormData(`/api/admin/members/${encodeURIComponent(name)}`, form, 'PUT');
|
||||
|
||||
// 목록 캐시 무효화 (목록 페이지에서 최신 데이터 표시)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
|
||||
// 목록 페이지로 이동하면서 토스트 메시지 전달
|
||||
navigate('/admin/members', {
|
||||
state: { toast: { message: '멤버 정보가 수정되었습니다.', type: 'success' } }
|
||||
});
|
||||
} catch (err) {
|
||||
setToast({ message: err.message || '멤버 수정에 실패했습니다.', type: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,57 +1,17 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Edit2,
|
||||
Home, ChevronRight, Users, User
|
||||
} from 'lucide-react';
|
||||
import { Edit2, Home, ChevronRight, Users, User } from 'lucide-react';
|
||||
import Toast from '../../../components/Toast';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
|
||||
// 임시 더미 데이터 (API 구현 전까지 사용)
|
||||
const DUMMY_MEMBERS = [
|
||||
{ id: 1, name: '이새롬', name_en: 'SAEROM', is_former: true, image_url: null },
|
||||
{ id: 2, name: '송하영', name_en: 'HAYOUNG', is_former: false, image_url: null },
|
||||
{ id: 3, name: '장규리', name_en: 'GYURI', is_former: true, image_url: null },
|
||||
{ id: 4, name: '박지원', name_en: 'JIWON', is_former: false, image_url: null },
|
||||
{ id: 5, name: '노지선', name_en: 'JISUN', is_former: true, image_url: null },
|
||||
{ id: 6, name: '이서연', name_en: 'SEOYEON', is_former: true, image_url: null },
|
||||
{ id: 7, name: '이채영', name_en: 'CHAEYOUNG', is_former: false, image_url: null },
|
||||
{ id: 8, name: '이나경', name_en: 'NAKYUNG', is_former: false, image_url: null },
|
||||
{ id: 9, name: '백지헌', name_en: 'JIHEON', is_former: false, image_url: null },
|
||||
];
|
||||
|
||||
function AdminMembers() {
|
||||
const navigate = useNavigate();
|
||||
const [members, setMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const { toast, setToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
// 로그인 확인
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const userData = localStorage.getItem('adminUser');
|
||||
|
||||
if (!token || !userData) {
|
||||
navigate('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(JSON.parse(userData));
|
||||
|
||||
// TODO: API 구현 후 fetchMembers()로 교체
|
||||
setMembers(DUMMY_MEMBERS);
|
||||
setLoading(false);
|
||||
}, [navigate]);
|
||||
|
||||
// 활동/탈퇴 멤버 분리 (is_former: 0=활동, 1=탈퇴)
|
||||
const activeMembers = members.filter(m => !m.is_former);
|
||||
const formerMembers = members.filter(m => m.is_former);
|
||||
import { fetchAdminApi } from '../../../api';
|
||||
|
||||
// 멤버 카드 컴포넌트
|
||||
const MemberCard = ({ member, index, isFormer = false }) => (
|
||||
function MemberCard({ member, index, isFormer = false, onClick }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
|
|
@ -62,9 +22,8 @@ function AdminMembers() {
|
|||
delay: index * 0.06
|
||||
}}
|
||||
className={`relative rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-all group cursor-pointer ${isFormer ? 'opacity-60' : ''}`}
|
||||
onClick={() => navigate(`/admin/members/${encodeURIComponent(member.name)}/edit`)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 프로필 이미지 */}
|
||||
<div className={`aspect-[3/4] bg-gray-100 relative overflow-hidden ${isFormer ? 'grayscale' : ''}`}>
|
||||
{member.image_url ? (
|
||||
<img
|
||||
|
|
@ -77,14 +36,10 @@ function AdminMembers() {
|
|||
<User size={48} className="text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이름 오버레이 - 하단 그라데이션 */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
<h3 className="text-lg font-bold text-white drop-shadow-lg">{member.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* 수정 버튼 오버레이 */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<div className="px-4 py-2 bg-white/90 backdrop-blur-sm text-gray-900 rounded-lg font-medium flex items-center gap-2 shadow-lg">
|
||||
<Edit2 size={16} />
|
||||
|
|
@ -94,6 +49,43 @@ function AdminMembers() {
|
|||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminMembers() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
const { toast, setToast } = useToast();
|
||||
|
||||
// 다른 페이지에서 전달된 토스트 메시지 처리
|
||||
useEffect(() => {
|
||||
if (location.state?.toast) {
|
||||
setToast(location.state.toast);
|
||||
window.history.replaceState({}, '');
|
||||
}
|
||||
}, [location.state, setToast]);
|
||||
|
||||
// 멤버 목록 조회
|
||||
const { data: members = [], isLoading: loading, isError } = useQuery({
|
||||
queryKey: ['admin', 'members'],
|
||||
queryFn: () => fetchAdminApi('/api/admin/members'),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// 에러 처리
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
setToast({ message: '멤버 목록을 불러오는데 실패했습니다.', type: 'error' });
|
||||
}
|
||||
}, [isError, setToast]);
|
||||
|
||||
// 활동/탈퇴 멤버 분리 (is_former: 0=활동, 1=탈퇴)
|
||||
const activeMembers = members.filter(m => !m.is_former);
|
||||
const formerMembers = members.filter(m => m.is_former);
|
||||
|
||||
const handleMemberClick = (memberName) => {
|
||||
navigate(`/admin/members/${encodeURIComponent(memberName)}/edit`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout user={user}>
|
||||
|
|
@ -137,7 +129,12 @@ function AdminMembers() {
|
|||
{/* 5열 그리드 */}
|
||||
<div className="grid grid-cols-5 gap-5">
|
||||
{activeMembers.map((member, index) => (
|
||||
<MemberCard key={member.id} member={member} index={index} />
|
||||
<MemberCard
|
||||
key={member.id}
|
||||
member={member}
|
||||
index={index}
|
||||
onClick={() => handleMemberClick(member.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -153,10 +150,16 @@ function AdminMembers() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* 5열 그리드 (탈퇴 멤버용 - 4명이면 4개만 표시) */}
|
||||
{/* 5열 그리드 */}
|
||||
<div className="grid grid-cols-5 gap-5">
|
||||
{formerMembers.map((member, index) => (
|
||||
<MemberCard key={member.id} member={member} index={index} isFormer />
|
||||
<MemberCard
|
||||
key={member.id}
|
||||
member={member}
|
||||
index={index}
|
||||
isFormer
|
||||
onClick={() => handleMemberClick(member.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue