멤버 관리 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"
|
"dev": "node --watch src/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.970.0",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/multipart": "^9.3.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"mysql2": "^3.12.0",
|
"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 Fastify from 'fastify';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
import config from './config/index.js';
|
import config from './config/index.js';
|
||||||
|
|
||||||
// 플러그인
|
// 플러그인
|
||||||
|
|
@ -23,6 +24,13 @@ export async function buildApp(opts = {}) {
|
||||||
// config 데코레이터 등록
|
// config 데코레이터 등록
|
||||||
fastify.decorate('config', config);
|
fastify.decorate('config', config);
|
||||||
|
|
||||||
|
// multipart 플러그인 등록 (파일 업로드용)
|
||||||
|
await fastify.register(multipart, {
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024, // 10MB
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 플러그인 등록 (순서 중요)
|
// 플러그인 등록 (순서 중요)
|
||||||
await fastify.register(dbPlugin);
|
await fastify.register(dbPlugin);
|
||||||
await fastify.register(redisPlugin);
|
await fastify.register(redisPlugin);
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,11 @@ export default {
|
||||||
secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026',
|
secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026',
|
||||||
expiresIn: '30d',
|
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 {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reply.status(401).send({ error: '인증이 필요합니다.' });
|
return reply.status(401).send({ error: '인증이 필요합니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import authRoutes from './auth.js';
|
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) {
|
export default async function adminRoutes(fastify, opts) {
|
||||||
// 인증 라우트 (prefix 없음)
|
// 인증 라우트 (prefix 없음)
|
||||||
fastify.register(authRoutes);
|
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 { useState, useEffect } from 'react';
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import {
|
import { Save, Upload, X, Home, ChevronRight, User, Instagram, Calendar, Tag } from 'lucide-react';
|
||||||
Save, Upload, X,
|
|
||||||
Home, ChevronRight, User, Instagram, Calendar, Tag
|
|
||||||
} from 'lucide-react';
|
|
||||||
import Toast from '../../../components/Toast';
|
import Toast from '../../../components/Toast';
|
||||||
import CustomDatePicker from '../../../components/admin/CustomDatePicker';
|
import CustomDatePicker from '../../../components/admin/CustomDatePicker';
|
||||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||||
|
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||||
import useToast from '../../../hooks/useToast';
|
import useToast from '../../../hooks/useToast';
|
||||||
|
import { fetchAdminApi, fetchAdminFormData } from '../../../api';
|
||||||
// 임시 더미 데이터 (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: ['지헌', '지허니'] },
|
|
||||||
};
|
|
||||||
|
|
||||||
function AdminMemberEdit() {
|
function AdminMemberEdit() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { name } = useParams();
|
const { name } = useParams();
|
||||||
const [user, setUser] = useState(null);
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const { toast, setToast } = useToast();
|
const { toast, setToast } = useToast();
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
const [imagePreview, setImagePreview] = useState(null);
|
const [imagePreview, setImagePreview] = useState(null);
|
||||||
const [imageFile, setImageFile] = useState(null);
|
const [imageFile, setImageFile] = useState(null);
|
||||||
const [nicknameInput, setNicknameInput] = useState('');
|
const [nicknameInput, setNicknameInput] = useState('');
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
name_en: '',
|
name_en: '',
|
||||||
|
|
@ -43,33 +30,37 @@ function AdminMemberEdit() {
|
||||||
nicknames: []
|
nicknames: []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 멤버 상세 조회
|
||||||
|
const { data: memberData, isLoading: loading, isError } = useQuery({
|
||||||
|
queryKey: ['admin', 'member', name],
|
||||||
|
queryFn: () => fetchAdminApi(`/api/admin/members/${encodeURIComponent(name)}`),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 데이터 로드 시 폼에 반영
|
||||||
useEffect(() => {
|
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) {
|
if (memberData) {
|
||||||
|
const birthDate = memberData.birth_date
|
||||||
|
? memberData.birth_date.split('T')[0]
|
||||||
|
: '';
|
||||||
setFormData({
|
setFormData({
|
||||||
name: memberData.name || '',
|
name: memberData.name || '',
|
||||||
name_en: memberData.name_en || '',
|
name_en: memberData.name_en || '',
|
||||||
birth_date: memberData.birth_date || '',
|
birth_date: birthDate,
|
||||||
instagram: memberData.instagram || '',
|
instagram: memberData.instagram || '',
|
||||||
is_former: !!memberData.is_former,
|
is_former: !!memberData.is_former,
|
||||||
nicknames: memberData.nicknames || []
|
nicknames: memberData.nicknames || []
|
||||||
});
|
});
|
||||||
setImagePreview(memberData.image_url);
|
setImagePreview(memberData.image_url);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
}, [memberData]);
|
||||||
}, [navigate, name]);
|
|
||||||
|
// 에러 처리
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError) {
|
||||||
|
setToast({ message: '멤버 정보를 불러오는데 실패했습니다.', type: 'error' });
|
||||||
|
}
|
||||||
|
}, [isError, setToast]);
|
||||||
|
|
||||||
const handleImageChange = (e) => {
|
const handleImageChange = (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
|
|
@ -113,13 +104,33 @@ function AdminMemberEdit() {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
// TODO: API 구현 후 실제 저장 로직 추가
|
try {
|
||||||
console.log('저장할 데이터:', formData, imageFile);
|
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(() => {
|
if (imageFile) {
|
||||||
setToast({ message: '멤버 정보가 수정되었습니다. (API 미구현)', type: 'success' });
|
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);
|
setSaving(false);
|
||||||
}, 500);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,17 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import {
|
import { Edit2, Home, ChevronRight, Users, User } from 'lucide-react';
|
||||||
Edit2,
|
|
||||||
Home, ChevronRight, Users, User
|
|
||||||
} from 'lucide-react';
|
|
||||||
import Toast from '../../../components/Toast';
|
import Toast from '../../../components/Toast';
|
||||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||||
|
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||||
import useToast from '../../../hooks/useToast';
|
import useToast from '../../../hooks/useToast';
|
||||||
|
import { fetchAdminApi } from '../../../api';
|
||||||
|
|
||||||
// 임시 더미 데이터 (API 구현 전까지 사용)
|
// 멤버 카드 컴포넌트
|
||||||
const DUMMY_MEMBERS = [
|
function MemberCard({ member, index, isFormer = false, onClick }) {
|
||||||
{ id: 1, name: '이새롬', name_en: 'SAEROM', is_former: true, image_url: null },
|
return (
|
||||||
{ 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);
|
|
||||||
|
|
||||||
// 멤버 카드 컴포넌트
|
|
||||||
const MemberCard = ({ member, index, isFormer = false }) => (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
|
@ -62,9 +22,8 @@ function AdminMembers() {
|
||||||
delay: index * 0.06
|
delay: index * 0.06
|
||||||
}}
|
}}
|
||||||
className={`relative rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-all group cursor-pointer ${isFormer ? 'opacity-60' : ''}`}
|
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' : ''}`}>
|
<div className={`aspect-[3/4] bg-gray-100 relative overflow-hidden ${isFormer ? 'grayscale' : ''}`}>
|
||||||
{member.image_url ? (
|
{member.image_url ? (
|
||||||
<img
|
<img
|
||||||
|
|
@ -77,14 +36,10 @@ function AdminMembers() {
|
||||||
<User size={48} className="text-gray-300" />
|
<User size={48} className="text-gray-300" />
|
||||||
</div>
|
</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 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">
|
<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>
|
<h3 className="text-lg font-bold text-white drop-shadow-lg">{member.name}</h3>
|
||||||
</div>
|
</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="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">
|
<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} />
|
<Edit2 size={16} />
|
||||||
|
|
@ -94,6 +49,43 @@ function AdminMembers() {
|
||||||
</div>
|
</div>
|
||||||
</motion.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 (
|
return (
|
||||||
<AdminLayout user={user}>
|
<AdminLayout user={user}>
|
||||||
|
|
@ -137,7 +129,12 @@ function AdminMembers() {
|
||||||
{/* 5열 그리드 */}
|
{/* 5열 그리드 */}
|
||||||
<div className="grid grid-cols-5 gap-5">
|
<div className="grid grid-cols-5 gap-5">
|
||||||
{activeMembers.map((member, index) => (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -153,10 +150,16 @@ function AdminMembers() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 5열 그리드 (탈퇴 멤버용 - 4명이면 4개만 표시) */}
|
{/* 5열 그리드 */}
|
||||||
<div className="grid grid-cols-5 gap-5">
|
<div className="grid grid-cols-5 gap-5">
|
||||||
{formerMembers.map((member, index) => (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue