Compare commits
88 commits
f483f2cf53
...
218b825878
| Author | SHA1 | Date | |
|---|---|---|---|
| 218b825878 | |||
| 08d704da5c | |||
| 81a2112b59 | |||
| d2f6670795 | |||
| ecd988aa8a | |||
| cf8cdb7ec6 | |||
| f436cf4367 | |||
| e31cb82649 | |||
| cbce382d94 | |||
| cfd14e01e5 | |||
| 5f9f9789aa | |||
| 6a96b8a5f9 | |||
| 362182fb53 | |||
| 27fc26ee96 | |||
| d5c54db86c | |||
| 21dde0fd35 | |||
| b314b70014 | |||
| 25d74d098d | |||
| 1d5626568a | |||
| 2a50a07a29 | |||
| 9515db712d | |||
| ff5c168529 | |||
| 7dc3ec692e | |||
| dc5ac97717 | |||
| 66fef502ff | |||
| f64f6cee00 | |||
| d9ea716089 | |||
| 28d408ace5 | |||
| 4a26369dff | |||
| 67a41d78ea | |||
| 58f359adfa | |||
| 772eda21e0 | |||
| a0fc67adae | |||
| 116d41ff07 | |||
| 21639171e1 | |||
| 791a3d699a | |||
| 934fbb8ed6 | |||
| 6cbe4fe6e2 | |||
| 97d6148280 | |||
| 57d4f1dd5c | |||
| d9b8e67b9a | |||
| 72b3800ce7 | |||
| 0ce67d57e8 | |||
| 276ef509f0 | |||
| 9d1f54c68a | |||
| b49e5e277d | |||
| 97850b12c1 | |||
| dd0e508117 | |||
| 76e0c2ee72 | |||
| 7a076aaffd | |||
| 2ead24065b | |||
| 4dcd79504d | |||
| b64710c8fd | |||
| 3922d5c6f7 | |||
| 55096c8e43 | |||
| e136f3c74b | |||
| 51063a120a | |||
| 22ce21f908 | |||
| 0255b35616 | |||
| 9b96c475a7 | |||
| d660340cc5 | |||
| 6a6f45798a | |||
| 8ec7aa0e60 | |||
| edde52b06e | |||
| 86bf2359f2 | |||
| 2d42bf1603 | |||
| e0ab3ce0f8 | |||
| bd8e87f636 | |||
| 6c25d32259 | |||
| 5e03e48be5 | |||
| 81b78be010 | |||
| 64fc07044d | |||
| 84030019cd | |||
| 27c41b0af0 | |||
| fe067ca8c8 | |||
| cba7e4b522 | |||
| dc63a91f4f | |||
| 4ec368c936 | |||
| 9d365dcadb | |||
| b4c393c20a | |||
| 3ee41beb46 | |||
| a62cf7142b | |||
| bfdbc08405 | |||
| 3f27b1f457 | |||
| fec2a4455c | |||
| b0ac0e51e4 | |||
| 5cc258b009 | |||
| f3c084069f |
176 changed files with 25036 additions and 1113 deletions
21
backend/package-lock.json
generated
21
backend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.970.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/static": "^8.0.0",
|
||||
|
|
@ -978,6 +979,26 @@
|
|||
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||
"integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/deepmerge": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.970.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/static": "^8.0.0",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import Fastify from 'fastify';
|
||||
import fastifyCors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifySwagger from '@fastify/swagger';
|
||||
import scalarApiReference from '@scalar/fastify-api-reference';
|
||||
import multipart from '@fastify/multipart';
|
||||
import config from './config/index.js';
|
||||
import * as schemas from './schemas/index.js';
|
||||
|
|
@ -35,6 +35,14 @@ export async function buildApp(opts = {}) {
|
|||
// config 데코레이터 등록
|
||||
fastify.decorate('config', config);
|
||||
|
||||
// CORS 설정 (API 문서 포털에서 테스트 요청 허용)
|
||||
await fastify.register(fastifyCors, {
|
||||
origin: ['https://docs.caadiq.co.kr'],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// multipart 플러그인 등록 (파일 업로드용)
|
||||
await fastify.register(multipart, {
|
||||
limits: {
|
||||
|
|
@ -92,17 +100,6 @@ export async function buildApp(opts = {}) {
|
|||
},
|
||||
});
|
||||
|
||||
// Scalar API Reference UI
|
||||
await fastify.register(scalarApiReference, {
|
||||
routePrefix: '/docs',
|
||||
configuration: {
|
||||
theme: 'purple',
|
||||
spec: {
|
||||
url: '/docs/json',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// OpenAPI JSON 엔드포인트
|
||||
fastify.get('/docs/json', { schema: { hide: true } }, async () => {
|
||||
return fastify.swagger();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import {
|
||||
getAlbumDetails,
|
||||
getAlbumsWithTracks,
|
||||
getAlbumByName,
|
||||
getAlbumById,
|
||||
createAlbum,
|
||||
updateAlbum,
|
||||
deleteAlbum,
|
||||
invalidateAlbumCache,
|
||||
} from '../../services/album.js';
|
||||
import photosRoutes from './photos.js';
|
||||
import teasersRoutes from './teasers.js';
|
||||
|
|
@ -14,7 +17,7 @@ import { errorResponse, successResponse, idParam } from '../../schemas/index.js'
|
|||
* GET: 공개, POST/PUT/DELETE: 인증 필요
|
||||
*/
|
||||
export default async function albumsRoutes(fastify) {
|
||||
const { db } = fastify;
|
||||
const { db, redis } = fastify;
|
||||
|
||||
// 하위 라우트 등록
|
||||
fastify.register(photosRoutes);
|
||||
|
|
@ -35,7 +38,7 @@ export default async function albumsRoutes(fastify) {
|
|||
},
|
||||
},
|
||||
}, async () => {
|
||||
return await getAlbumsWithTracks(db);
|
||||
return await getAlbumsWithTracks(db, redis);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -62,17 +65,11 @@ export default async function albumsRoutes(fastify) {
|
|||
const albumName = decodeURIComponent(request.params.albumName);
|
||||
const trackTitle = decodeURIComponent(request.params.trackTitle);
|
||||
|
||||
const [albums] = await db.query(
|
||||
'SELECT * FROM albums WHERE folder_name = ? OR title = ?',
|
||||
[albumName, albumName]
|
||||
);
|
||||
|
||||
if (albums.length === 0) {
|
||||
const album = await getAlbumByName(db, albumName);
|
||||
if (!album) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const album = albums[0];
|
||||
|
||||
const [tracks] = await db.query(
|
||||
'SELECT * FROM album_tracks WHERE album_id = ? AND title = ?',
|
||||
[album.id, trackTitle]
|
||||
|
|
@ -124,18 +121,11 @@ export default async function albumsRoutes(fastify) {
|
|||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const name = decodeURIComponent(request.params.name);
|
||||
|
||||
const [albums] = await db.query(
|
||||
'SELECT * FROM albums WHERE folder_name = ? OR title = ?',
|
||||
[name, name]
|
||||
);
|
||||
|
||||
if (albums.length === 0) {
|
||||
const album = await getAlbumByName(db, decodeURIComponent(request.params.name));
|
||||
if (!album) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
return getAlbumDetails(db, albums[0]);
|
||||
return getAlbumDetails(db, album, redis);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -152,15 +142,11 @@ export default async function albumsRoutes(fastify) {
|
|||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [
|
||||
request.params.id,
|
||||
]);
|
||||
|
||||
if (albums.length === 0) {
|
||||
const album = await getAlbumById(db, request.params.id);
|
||||
if (!album) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
return getAlbumDetails(db, albums[0]);
|
||||
return getAlbumDetails(db, album, redis);
|
||||
});
|
||||
|
||||
// ==================== POST/PUT/DELETE (인증 필요) ====================
|
||||
|
|
@ -210,7 +196,9 @@ export default async function albumsRoutes(fastify) {
|
|||
return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' });
|
||||
}
|
||||
|
||||
return await createAlbum(db, data, coverBuffer);
|
||||
const result = await createAlbum(db, data, coverBuffer);
|
||||
await invalidateAlbumCache(redis);
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -253,6 +241,7 @@ export default async function albumsRoutes(fastify) {
|
|||
if (!result) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
await invalidateAlbumCache(redis, id);
|
||||
return result;
|
||||
});
|
||||
|
||||
|
|
@ -278,6 +267,7 @@ export default async function albumsRoutes(fastify) {
|
|||
if (!result) {
|
||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||
}
|
||||
await invalidateAlbumCache(redis, id);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { uploadMemberImage } from '../../services/image.js';
|
||||
import { getAllMembers, getMemberByName, getMemberBasicByName, invalidateMemberCache } from '../../services/member.js';
|
||||
|
||||
/**
|
||||
* 멤버 라우트
|
||||
* GET: 공개, PUT: 인증 필요
|
||||
*/
|
||||
export default async function membersRoutes(fastify, opts) {
|
||||
const { db } = fastify;
|
||||
const { db, redis } = fastify;
|
||||
|
||||
/**
|
||||
* GET /api/members
|
||||
* 전체 멤버 목록 조회 (공개)
|
||||
* 전체 멤버 목록 조회 (공개, 캐시 적용)
|
||||
*/
|
||||
fastify.get('/', {
|
||||
schema: {
|
||||
|
|
@ -18,39 +19,7 @@ export default async function membersRoutes(fastify, opts) {
|
|||
},
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const [members] = await 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 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;
|
||||
return await getAllMembers(db, redis);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.code(500).send({ error: '멤버 목록 조회 실패' });
|
||||
|
|
@ -73,37 +42,12 @@ export default async function membersRoutes(fastify, opts) {
|
|||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { name } = request.params;
|
||||
|
||||
try {
|
||||
const [members] = await 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) {
|
||||
const member = await getMemberByName(db, decodeURIComponent(request.params.name));
|
||||
if (!member) {
|
||||
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const member = members[0];
|
||||
|
||||
// 별명 조회
|
||||
const [nicknames] = await 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,
|
||||
};
|
||||
return member;
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.code(500).send({ error: '멤버 조회 실패' });
|
||||
|
|
@ -134,17 +78,13 @@ export default async function membersRoutes(fastify, opts) {
|
|||
|
||||
try {
|
||||
// 기존 멤버 조회
|
||||
const [existing] = await db.query(
|
||||
'SELECT id, image_id FROM members WHERE name = ?',
|
||||
[decodedName]
|
||||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
const existing = await getMemberBasicByName(db, decodedName);
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const memberId = existing[0].id;
|
||||
let imageId = existing[0].image_id;
|
||||
const memberId = existing.id;
|
||||
let imageId = existing.image_id;
|
||||
|
||||
// multipart 데이터 파싱
|
||||
const parts = request.parts();
|
||||
|
|
@ -215,6 +155,9 @@ export default async function membersRoutes(fastify, opts) {
|
|||
}
|
||||
}
|
||||
|
||||
// 멤버 캐시 무효화
|
||||
await invalidateMemberCache(redis);
|
||||
|
||||
return { message: '멤버 정보가 수정되었습니다', id: memberId };
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@
|
|||
*/
|
||||
import suggestionsRoutes from './suggestions.js';
|
||||
import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/index.js';
|
||||
import config, { CATEGORY_IDS } from '../../config/index.js';
|
||||
import { getMonthlySchedules, getUpcomingSchedules } from '../../services/schedule.js';
|
||||
import { CATEGORY_IDS } from '../../config/index.js';
|
||||
import {
|
||||
getCategories,
|
||||
getScheduleDetail,
|
||||
getMonthlySchedules,
|
||||
getUpcomingSchedules,
|
||||
} from '../../services/schedule.js';
|
||||
import {
|
||||
errorResponse,
|
||||
scheduleSearchQuery,
|
||||
|
|
@ -33,10 +38,12 @@ export default async function schedulesRoutes(fastify) {
|
|||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const [categories] = await db.query(
|
||||
'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC'
|
||||
);
|
||||
return categories;
|
||||
try {
|
||||
return await getCategories(db, redis);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.code(500).send({ error: '카테고리 목록 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -51,28 +58,38 @@ export default async function schedulesRoutes(fastify) {
|
|||
description: 'search 파라미터로 검색, year/month로 월별 조회, startDate로 다가오는 일정 조회',
|
||||
querystring: scheduleSearchQuery,
|
||||
response: {
|
||||
200: { type: 'object', additionalProperties: true },
|
||||
200: {
|
||||
oneOf: [
|
||||
{ type: 'object', additionalProperties: true },
|
||||
{ type: 'array', items: { type: 'object', additionalProperties: true } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { search, year, month, startDate, offset = 0, limit = 100 } = request.query;
|
||||
try {
|
||||
const { search, year, month, startDate, offset = 0, limit = 100 } = request.query;
|
||||
|
||||
// 검색 모드
|
||||
if (search && search.trim()) {
|
||||
return await handleSearch(fastify, search.trim(), parseInt(offset), parseInt(limit));
|
||||
// 검색 모드
|
||||
if (search && search.trim()) {
|
||||
return await handleSearch(fastify, search.trim(), parseInt(offset), parseInt(limit));
|
||||
}
|
||||
|
||||
// 다가오는 일정 조회 (startDate부터)
|
||||
if (startDate) {
|
||||
return await getUpcomingSchedules(db, startDate, parseInt(limit));
|
||||
}
|
||||
|
||||
// 월별 조회 모드
|
||||
if (!year || !month) {
|
||||
return reply.code(400).send({ error: 'search, startDate, 또는 year/month는 필수입니다.' });
|
||||
}
|
||||
|
||||
return await getMonthlySchedules(db, parseInt(year), parseInt(month));
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.code(500).send({ error: '일정 조회 실패' });
|
||||
}
|
||||
|
||||
// 다가오는 일정 조회 (startDate부터)
|
||||
if (startDate) {
|
||||
return await getUpcomingSchedules(db, startDate, parseInt(limit));
|
||||
}
|
||||
|
||||
// 월별 조회 모드
|
||||
if (!year || !month) {
|
||||
return reply.code(400).send({ error: 'search, startDate, 또는 year/month는 필수입니다.' });
|
||||
}
|
||||
|
||||
return await getMonthlySchedules(db, parseInt(year), parseInt(month));
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -97,8 +114,13 @@ export default async function schedulesRoutes(fastify) {
|
|||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const count = await syncAllSchedules(meilisearch, db);
|
||||
return { success: true, synced: count };
|
||||
try {
|
||||
const count = await syncAllSchedules(meilisearch, db);
|
||||
return { success: true, synced: count };
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.code(500).send({ error: '동기화 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -116,90 +138,22 @@ export default async function schedulesRoutes(fastify) {
|
|||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
try {
|
||||
const result = await getScheduleDetail(
|
||||
db,
|
||||
request.params.id,
|
||||
(username) => fastify.xBot.getProfile(username)
|
||||
);
|
||||
|
||||
const [schedules] = await db.query(`
|
||||
SELECT
|
||||
s.*,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
sy.channel_name as youtube_channel,
|
||||
sy.video_id as youtube_video_id,
|
||||
sy.video_type as youtube_video_type,
|
||||
sx.post_id as x_post_id,
|
||||
sx.content as x_content,
|
||||
sx.image_urls as x_image_urls
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const s = schedules[0];
|
||||
|
||||
// 멤버 정보 조회
|
||||
const [members] = await db.query(`
|
||||
SELECT m.id, m.name
|
||||
FROM schedule_members sm
|
||||
JOIN members m ON sm.member_id = m.id
|
||||
WHERE sm.schedule_id = ?
|
||||
ORDER BY m.id
|
||||
`, [id]);
|
||||
|
||||
// datetime 생성 (date + time)
|
||||
const dateStr = s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date?.split('T')[0];
|
||||
const timeStr = s.time ? s.time.slice(0, 5) : null;
|
||||
const datetime = timeStr ? `${dateStr} ${timeStr}` : dateStr;
|
||||
|
||||
// 공통 필드
|
||||
const result = {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
datetime,
|
||||
category: {
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
},
|
||||
members,
|
||||
createdAt: s.created_at,
|
||||
updatedAt: s.updated_at,
|
||||
};
|
||||
|
||||
// 카테고리별 추가 필드
|
||||
if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) {
|
||||
// YouTube
|
||||
result.videoId = s.youtube_video_id;
|
||||
result.videoType = s.youtube_video_type;
|
||||
result.channelName = s.youtube_channel;
|
||||
result.videoUrl = s.youtube_video_type === 'shorts'
|
||||
? `https://www.youtube.com/shorts/${s.youtube_video_id}`
|
||||
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
|
||||
} else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) {
|
||||
// X (Twitter)
|
||||
const username = config.x.defaultUsername;
|
||||
result.postId = s.x_post_id;
|
||||
result.content = s.x_content || null;
|
||||
result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : [];
|
||||
result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`;
|
||||
|
||||
// 프로필 정보 (Redis 캐시 → DB)
|
||||
const profile = await fastify.xBot.getProfile(username);
|
||||
if (profile) {
|
||||
result.profile = {
|
||||
username: profile.username,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
if (!result) {
|
||||
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.code(500).send({ error: '일정 상세 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -225,32 +179,37 @@ export default async function schedulesRoutes(fastify) {
|
|||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
// 일정 존재 확인
|
||||
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
|
||||
if (existing.length === 0) {
|
||||
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 관련 테이블 삭제 (외래 키)
|
||||
await db.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]);
|
||||
await db.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]);
|
||||
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
|
||||
await db.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]);
|
||||
|
||||
// 메인 테이블 삭제
|
||||
await db.query('DELETE FROM schedules WHERE id = ?', [id]);
|
||||
|
||||
// Meilisearch에서도 삭제
|
||||
try {
|
||||
const { deleteSchedule } = await import('../../services/meilisearch/index.js');
|
||||
await deleteSchedule(meilisearch, id);
|
||||
} catch (err) {
|
||||
fastify.log.error(`Meilisearch 삭제 오류: ${err.message}`);
|
||||
}
|
||||
const { id } = request.params;
|
||||
|
||||
return { success: true };
|
||||
// 일정 존재 확인
|
||||
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
|
||||
if (existing.length === 0) {
|
||||
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 관련 테이블 삭제 (외래 키)
|
||||
await db.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]);
|
||||
await db.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]);
|
||||
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
|
||||
await db.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]);
|
||||
|
||||
// 메인 테이블 삭제
|
||||
await db.query('DELETE FROM schedules WHERE id = ?', [id]);
|
||||
|
||||
// Meilisearch에서도 삭제
|
||||
try {
|
||||
const { deleteSchedule } = await import('../../services/meilisearch/index.js');
|
||||
await deleteSchedule(meilisearch, id);
|
||||
} catch (meiliErr) {
|
||||
fastify.log.error(`Meilisearch 삭제 오류: ${meiliErr.message}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
return reply.code(500).send({ error: '일정 삭제 실패' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -258,7 +217,7 @@ export default async function schedulesRoutes(fastify) {
|
|||
* 검색 처리
|
||||
*/
|
||||
async function handleSearch(fastify, query, offset, limit) {
|
||||
const { db, meilisearch, redis } = fastify;
|
||||
const { db, meilisearch } = fastify;
|
||||
|
||||
// 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습)
|
||||
if (offset === 0) {
|
||||
|
|
@ -266,18 +225,15 @@ async function handleSearch(fastify, query, offset, limit) {
|
|||
saveSearchQueryAsync(fastify, query);
|
||||
}
|
||||
|
||||
// Meilisearch 검색
|
||||
const results = await searchSchedules(meilisearch, db, query, { limit: 1000 });
|
||||
|
||||
// 페이징 적용
|
||||
const paginatedHits = results.hits.slice(offset, offset + limit);
|
||||
// Meilisearch 검색 (페이징 포함)
|
||||
const results = await searchSchedules(meilisearch, db, query, { offset, limit });
|
||||
|
||||
return {
|
||||
schedules: paginatedHits,
|
||||
schedules: results.hits,
|
||||
total: results.total,
|
||||
offset,
|
||||
limit,
|
||||
hasMore: offset + paginatedHits.length < results.total,
|
||||
offset: results.offset,
|
||||
limit: results.limit,
|
||||
hasMore: results.hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,104 +4,162 @@
|
|||
*/
|
||||
import { uploadAlbumCover, deleteAlbumCover } from './image.js';
|
||||
import { withTransaction } from '../utils/transaction.js';
|
||||
import { getOrSet, invalidate, invalidatePattern, cacheKeys, TTL } from '../utils/cache.js';
|
||||
|
||||
/**
|
||||
* 앨범 상세 정보 조회 (트랙, 티저, 컨셉포토 포함)
|
||||
* 앨범명 또는 폴더명으로 앨범 조회
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {object} album - 앨범 기본 정보
|
||||
* @returns {object} 상세 정보가 포함된 앨범
|
||||
* @param {string} name - 앨범명 또는 폴더명
|
||||
* @returns {object|null} 앨범 정보 또는 null
|
||||
*/
|
||||
export async function getAlbumDetails(db, album) {
|
||||
// 트랙, 티저, 포토 병렬 조회
|
||||
const [[tracks], [teasers], [photos]] = await Promise.all([
|
||||
db.query(
|
||||
'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number',
|
||||
[album.id]
|
||||
),
|
||||
db.query(
|
||||
`SELECT original_url, medium_url, thumb_url, video_url, media_type
|
||||
FROM album_teasers WHERE album_id = ? ORDER BY sort_order`,
|
||||
[album.id]
|
||||
),
|
||||
db.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,
|
||||
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
|
||||
FROM album_photos p
|
||||
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
|
||||
LEFT JOIN members m ON pm.member_id = m.id
|
||||
WHERE p.album_id = ?
|
||||
GROUP BY p.id
|
||||
ORDER BY p.sort_order`,
|
||||
[album.id]
|
||||
),
|
||||
]);
|
||||
|
||||
album.tracks = tracks;
|
||||
album.teasers = teasers;
|
||||
|
||||
const conceptPhotos = {};
|
||||
for (const photo of photos) {
|
||||
const concept = photo.concept_name || 'Default';
|
||||
if (!conceptPhotos[concept]) {
|
||||
conceptPhotos[concept] = [];
|
||||
}
|
||||
conceptPhotos[concept].push({
|
||||
id: photo.id,
|
||||
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,
|
||||
members: photo.members,
|
||||
sortOrder: photo.sort_order,
|
||||
});
|
||||
}
|
||||
album.conceptPhotos = conceptPhotos;
|
||||
|
||||
return album;
|
||||
export async function getAlbumByName(db, name) {
|
||||
const [albums] = await db.query(
|
||||
'SELECT * FROM albums WHERE folder_name = ? OR title = ?',
|
||||
[name, name]
|
||||
);
|
||||
return albums.length > 0 ? albums[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 목록과 트랙 조회 (N+1 최적화)
|
||||
* ID로 앨범 조회
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {number} id - 앨범 ID
|
||||
* @returns {object|null} 앨범 정보 또는 null
|
||||
*/
|
||||
export async function getAlbumById(db, id) {
|
||||
const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]);
|
||||
return albums.length > 0 ? albums[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 상세 정보 조회 (트랙, 티저, 컨셉포토 포함, 캐시 적용)
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {object} album - 앨범 기본 정보
|
||||
* @param {object} redis - Redis 클라이언트 (선택적)
|
||||
* @returns {object} 상세 정보가 포함된 앨범
|
||||
*/
|
||||
export async function getAlbumDetails(db, album, redis = null) {
|
||||
const fetchDetails = async () => {
|
||||
// 트랙, 티저, 포토 병렬 조회
|
||||
const [[tracks], [teasers], [photos]] = await Promise.all([
|
||||
db.query(
|
||||
'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number',
|
||||
[album.id]
|
||||
),
|
||||
db.query(
|
||||
`SELECT original_url, medium_url, thumb_url, video_url, media_type
|
||||
FROM album_teasers WHERE album_id = ? ORDER BY sort_order`,
|
||||
[album.id]
|
||||
),
|
||||
db.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,
|
||||
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
|
||||
FROM album_photos p
|
||||
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
|
||||
LEFT JOIN members m ON pm.member_id = m.id
|
||||
WHERE p.album_id = ?
|
||||
GROUP BY p.id
|
||||
ORDER BY p.sort_order`,
|
||||
[album.id]
|
||||
),
|
||||
]);
|
||||
|
||||
const result = { ...album };
|
||||
result.tracks = tracks;
|
||||
result.teasers = teasers;
|
||||
|
||||
const conceptPhotos = {};
|
||||
for (const photo of photos) {
|
||||
const concept = photo.concept_name || 'Default';
|
||||
if (!conceptPhotos[concept]) {
|
||||
conceptPhotos[concept] = [];
|
||||
}
|
||||
conceptPhotos[concept].push({
|
||||
id: photo.id,
|
||||
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,
|
||||
members: photo.members,
|
||||
sortOrder: photo.sort_order,
|
||||
});
|
||||
}
|
||||
result.conceptPhotos = conceptPhotos;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
if (redis) {
|
||||
return getOrSet(redis, cacheKeys.albumDetail(album.id), fetchDetails, TTL.LONG);
|
||||
}
|
||||
return fetchDetails();
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 목록과 트랙 조회 (N+1 최적화, 캐시 적용)
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {object} redis - Redis 클라이언트 (선택적)
|
||||
* @returns {array} 트랙 포함된 앨범 목록
|
||||
*/
|
||||
export async function getAlbumsWithTracks(db) {
|
||||
const [albums] = await db.query(`
|
||||
SELECT id, title, folder_name, album_type, album_type_short, release_date,
|
||||
cover_original_url, cover_medium_url, cover_thumb_url, description
|
||||
FROM albums
|
||||
ORDER BY release_date DESC
|
||||
`);
|
||||
export async function getAlbumsWithTracks(db, redis = null) {
|
||||
const fetchAlbums = async () => {
|
||||
const [albums] = await db.query(`
|
||||
SELECT id, title, folder_name, album_type, album_type_short, release_date,
|
||||
cover_original_url, cover_medium_url, cover_thumb_url, description
|
||||
FROM albums
|
||||
ORDER BY release_date DESC
|
||||
`);
|
||||
|
||||
if (albums.length === 0) return albums;
|
||||
if (albums.length === 0) return albums;
|
||||
|
||||
// 모든 트랙을 한 번에 조회
|
||||
const albumIds = albums.map(a => a.id);
|
||||
const [allTracks] = await db.query(
|
||||
`SELECT id, album_id, track_number, title, is_title_track, duration, lyricist, composer, arranger
|
||||
FROM album_tracks WHERE album_id IN (?) ORDER BY album_id, track_number`,
|
||||
[albumIds]
|
||||
);
|
||||
// 모든 트랙을 한 번에 조회
|
||||
const albumIds = albums.map(a => a.id);
|
||||
const [allTracks] = await db.query(
|
||||
`SELECT id, album_id, track_number, title, is_title_track, duration, lyricist, composer, arranger
|
||||
FROM album_tracks WHERE album_id IN (?) ORDER BY album_id, track_number`,
|
||||
[albumIds]
|
||||
);
|
||||
|
||||
// 앨범 ID별로 트랙 그룹화
|
||||
const tracksByAlbum = {};
|
||||
for (const track of allTracks) {
|
||||
if (!tracksByAlbum[track.album_id]) {
|
||||
tracksByAlbum[track.album_id] = [];
|
||||
// 앨범 ID별로 트랙 그룹화
|
||||
const tracksByAlbum = {};
|
||||
for (const track of allTracks) {
|
||||
if (!tracksByAlbum[track.album_id]) {
|
||||
tracksByAlbum[track.album_id] = [];
|
||||
}
|
||||
tracksByAlbum[track.album_id].push(track);
|
||||
}
|
||||
tracksByAlbum[track.album_id].push(track);
|
||||
}
|
||||
|
||||
// 각 앨범에 트랙 할당
|
||||
for (const album of albums) {
|
||||
album.tracks = tracksByAlbum[album.id] || [];
|
||||
}
|
||||
// 각 앨범에 트랙 할당
|
||||
for (const album of albums) {
|
||||
album.tracks = tracksByAlbum[album.id] || [];
|
||||
}
|
||||
|
||||
return albums;
|
||||
return albums;
|
||||
};
|
||||
|
||||
if (redis) {
|
||||
return getOrSet(redis, cacheKeys.albums, fetchAlbums, TTL.LONG);
|
||||
}
|
||||
return fetchAlbums();
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 캐시 무효화
|
||||
* @param {object} redis - Redis 클라이언트
|
||||
* @param {number} albumId - 앨범 ID (선택적, 특정 앨범만 무효화)
|
||||
*/
|
||||
export async function invalidateAlbumCache(redis, albumId = null) {
|
||||
const keys = [cacheKeys.albums];
|
||||
if (albumId) {
|
||||
keys.push(cacheKeys.albumDetail(albumId));
|
||||
}
|
||||
await invalidate(redis, keys);
|
||||
// 앨범 이름 기반 캐시도 무효화 (패턴 매칭)
|
||||
await invalidatePattern(redis, 'album:name:*');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -24,21 +24,33 @@ const { medium, thumb } = config.image;
|
|||
|
||||
/**
|
||||
* 이미지를 3가지 해상도로 변환
|
||||
* @param {Buffer} buffer - 원본 이미지 버퍼
|
||||
* @param {boolean} includeMetadata - 메타데이터 포함 여부
|
||||
* @returns {Promise<{originalBuffer, mediumBuffer, thumbBuffer, metadata?}>}
|
||||
*/
|
||||
async function processImage(buffer) {
|
||||
async function processImage(buffer, includeMetadata = false) {
|
||||
const image = sharp(buffer);
|
||||
|
||||
// 메타데이터가 필요한 경우 먼저 조회 (중복 sharp 인스턴스 생성 방지)
|
||||
const metadata = includeMetadata ? await image.metadata() : null;
|
||||
|
||||
const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([
|
||||
sharp(buffer).webp({ lossless: true }).toBuffer(),
|
||||
sharp(buffer)
|
||||
image.clone().webp({ lossless: true }).toBuffer(),
|
||||
image.clone()
|
||||
.resize(medium.width, null, { withoutEnlargement: true })
|
||||
.webp({ quality: medium.quality })
|
||||
.toBuffer(),
|
||||
sharp(buffer)
|
||||
image.clone()
|
||||
.resize(thumb.width, null, { withoutEnlargement: true })
|
||||
.webp({ quality: thumb.quality })
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
return { originalBuffer, mediumBuffer, thumbBuffer };
|
||||
const result = { originalBuffer, mediumBuffer, thumbBuffer };
|
||||
if (metadata) {
|
||||
result.metadata = metadata;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -146,8 +158,7 @@ export async function deleteAlbumCover(folderName) {
|
|||
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string, metadata: object}>}
|
||||
*/
|
||||
export async function uploadAlbumPhoto(folderName, subFolder, filename, buffer) {
|
||||
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
|
||||
const metadata = await sharp(originalBuffer).metadata();
|
||||
const { originalBuffer, mediumBuffer, thumbBuffer, metadata } = await processImage(buffer, true);
|
||||
|
||||
const basePath = `album/${folderName}/${subFolder}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@
|
|||
import Inko from 'inko';
|
||||
import config, { CATEGORY_IDS } from '../../config/index.js';
|
||||
import { createLogger } from '../../utils/logger.js';
|
||||
import { buildDatetime } from '../schedule.js';
|
||||
|
||||
const inko = new Inko();
|
||||
const logger = createLogger('Meilisearch');
|
||||
const INDEX_NAME = 'schedules';
|
||||
const MIN_SCORE = config.meilisearch.minScore;
|
||||
|
||||
// 캐시된 현재 활동 멤버 수 (동기화 시 갱신)
|
||||
let cachedActiveMemberCount = null;
|
||||
|
||||
/**
|
||||
* 영문 자판으로 입력된 검색어인지 확인
|
||||
*/
|
||||
|
|
@ -38,21 +42,38 @@ export async function resolveMemberNames(db, query) {
|
|||
return members.map(m => m.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 활동 멤버 수 조회 및 캐시
|
||||
*/
|
||||
async function getActiveMemberCount(db) {
|
||||
if (cachedActiveMemberCount === null) {
|
||||
const [[{ count }]] = await db.query(
|
||||
'SELECT COUNT(*) as count FROM members WHERE is_former = 0'
|
||||
);
|
||||
cachedActiveMemberCount = count;
|
||||
}
|
||||
return cachedActiveMemberCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 검색
|
||||
* @param {object} meilisearch - Meilisearch 클라이언트
|
||||
* @param {object} db - DB 연결 풀
|
||||
* @param {string} query - 검색어
|
||||
* @param {object} options - 검색 옵션
|
||||
* @param {object} options - 검색 옵션 (offset, limit for pagination)
|
||||
*/
|
||||
export async function searchSchedules(meilisearch, db, query, options = {}) {
|
||||
const { limit = 1000, offset = 0 } = options;
|
||||
const { limit = 100, offset = 0 } = options;
|
||||
// 내부 검색 한도 (여러 검색어 병합 및 유사도 필터링 전 충분한 결과 확보)
|
||||
const SEARCH_LIMIT = 1000;
|
||||
|
||||
try {
|
||||
// 현재 활동 멤버 수 캐시 (formatScheduleResponse에서 사용)
|
||||
await getActiveMemberCount(db);
|
||||
const index = meilisearch.index(INDEX_NAME);
|
||||
|
||||
const searchOptions = {
|
||||
limit,
|
||||
limit: SEARCH_LIMIT,
|
||||
offset: 0, // 내부적으로 전체 검색 후 필터링
|
||||
attributesToRetrieve: ['*'],
|
||||
showRankingScore: true,
|
||||
|
|
@ -120,28 +141,21 @@ export async function searchSchedules(meilisearch, db, query, options = {}) {
|
|||
|
||||
/**
|
||||
* 검색 결과 응답 형식 변환
|
||||
* schedule.js의 공통 포맷과 동일한 구조 반환
|
||||
* (Meilisearch 인덱스 필드명이 다르므로 별도 매핑 필요)
|
||||
*/
|
||||
function formatScheduleResponse(hit) {
|
||||
// date + time 합치기
|
||||
let datetime = null;
|
||||
if (hit.date) {
|
||||
const dateStr = hit.date instanceof Date
|
||||
? hit.date.toISOString().split('T')[0]
|
||||
: String(hit.date).split('T')[0];
|
||||
|
||||
if (hit.time) {
|
||||
datetime = `${dateStr}T${hit.time}`;
|
||||
} else {
|
||||
datetime = dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
// member_names를 배열로 변환
|
||||
const members = hit.member_names
|
||||
let members = hit.member_names
|
||||
? hit.member_names.split(',').map(name => name.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
// source 객체 구성 (X는 name 비움)
|
||||
// 전체 멤버인 경우 "프로미스나인"으로 대체
|
||||
if (cachedActiveMemberCount && members.length === cachedActiveMemberCount) {
|
||||
members = ['프로미스나인'];
|
||||
}
|
||||
|
||||
// source 객체 구성 (Meilisearch에는 URL 없음)
|
||||
let source = null;
|
||||
if (hit.category_id === CATEGORY_IDS.YOUTUBE && hit.source_name) {
|
||||
source = { name: hit.source_name, url: null };
|
||||
|
|
@ -152,7 +166,7 @@ function formatScheduleResponse(hit) {
|
|||
return {
|
||||
id: hit.id,
|
||||
title: hit.title,
|
||||
datetime,
|
||||
datetime: buildDatetime(hit.date, hit.time),
|
||||
category: {
|
||||
id: hit.category_id,
|
||||
name: hit.category_name,
|
||||
|
|
@ -160,7 +174,6 @@ function formatScheduleResponse(hit) {
|
|||
},
|
||||
source,
|
||||
members,
|
||||
_rankingScore: hit._rankingScore,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -209,7 +222,13 @@ export async function deleteSchedule(meilisearch, scheduleId) {
|
|||
*/
|
||||
export async function syncAllSchedules(meilisearch, db) {
|
||||
try {
|
||||
// DB에서 모든 일정 조회
|
||||
// 현재 활동 멤버 수 캐시 갱신
|
||||
const [[{ count }]] = await db.query(
|
||||
'SELECT COUNT(*) as count FROM members WHERE is_former = 0'
|
||||
);
|
||||
cachedActiveMemberCount = count;
|
||||
|
||||
// DB에서 모든 일정 조회 (탈퇴 멤버 제외)
|
||||
const [schedules] = await db.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
|
|
@ -226,7 +245,7 @@ export async function syncAllSchedules(meilisearch, db) {
|
|||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id
|
||||
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
|
||||
GROUP BY s.id
|
||||
`);
|
||||
|
||||
|
|
|
|||
112
backend/src/services/member.js
Normal file
112
backend/src/services/member.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* 멤버 서비스
|
||||
* 멤버 관련 비즈니스 로직
|
||||
*/
|
||||
import { getOrSet, invalidate, cacheKeys } from '../utils/cache.js';
|
||||
|
||||
/**
|
||||
* 전체 멤버 목록 조회 (별명 포함, 캐시 적용)
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {object} redis - Redis 클라이언트 (캐시용, 선택적)
|
||||
* @returns {array} 멤버 목록
|
||||
*/
|
||||
export async function getAllMembers(db, redis = null) {
|
||||
const fetchMembers = async () => {
|
||||
const [members] = await 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 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);
|
||||
}
|
||||
|
||||
// 멤버 데이터에 별명 추가
|
||||
return members.map(m => ({
|
||||
...m,
|
||||
nicknames: nicknameMap[m.id] || [],
|
||||
image_url: m.image_thumb || m.image_medium || m.image_original,
|
||||
}));
|
||||
};
|
||||
|
||||
// Redis가 있으면 캐시 사용
|
||||
if (redis) {
|
||||
return getOrSet(redis, cacheKeys.members, fetchMembers, 600); // 10분 캐시
|
||||
}
|
||||
return fetchMembers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 캐시 무효화
|
||||
* @param {object} redis - Redis 클라이언트
|
||||
*/
|
||||
export async function invalidateMemberCache(redis) {
|
||||
await invalidate(redis, cacheKeys.members);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이름으로 멤버 조회 (별명 포함)
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {string} name - 멤버 이름
|
||||
* @returns {object|null} 멤버 정보 또는 null
|
||||
*/
|
||||
export async function getMemberByName(db, name) {
|
||||
const [members] = await 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 = ?
|
||||
`, [name]);
|
||||
|
||||
if (members.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const member = members[0];
|
||||
|
||||
// 별명 조회
|
||||
const [nicknames] = await 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 멤버 기본 정보 조회 (수정용)
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {string} name - 멤버 이름
|
||||
* @returns {object|null} 멤버 기본 정보 또는 null
|
||||
*/
|
||||
export async function getMemberBasicByName(db, name) {
|
||||
const [members] = await db.query(
|
||||
'SELECT id, image_id FROM members WHERE name = ?',
|
||||
[name]
|
||||
);
|
||||
return members.length > 0 ? members[0] : null;
|
||||
}
|
||||
|
|
@ -3,62 +3,310 @@
|
|||
* 스케줄 관련 비즈니스 로직
|
||||
*/
|
||||
import config, { CATEGORY_IDS } from '../config/index.js';
|
||||
import { getOrSet, cacheKeys, TTL } from '../utils/cache.js';
|
||||
|
||||
// ==================== 공통 포맷팅 함수 ====================
|
||||
|
||||
/**
|
||||
* 날짜 문자열 정규화
|
||||
* @param {Date|string} date - 날짜
|
||||
* @returns {string} YYYY-MM-DD 형식
|
||||
*/
|
||||
export function normalizeDate(date) {
|
||||
if (!date) return '';
|
||||
if (date instanceof Date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
return String(date).split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* datetime 생성 (date + time)
|
||||
* @param {Date|string} date - 날짜
|
||||
* @param {string} time - 시간 (HH:mm:ss)
|
||||
* @returns {string} YYYY-MM-DDTHH:mm:ss 또는 YYYY-MM-DD
|
||||
*/
|
||||
export function buildDatetime(date, time) {
|
||||
const dateStr = normalizeDate(date);
|
||||
return time ? `${dateStr}T${time}` : dateStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* source 객체 생성
|
||||
* @param {object} schedule - 일정 원본 데이터
|
||||
* @returns {object|null} { name, url } 또는 null
|
||||
*/
|
||||
export function buildSource(schedule) {
|
||||
const { category_id, youtube_video_id, youtube_video_type, youtube_channel, x_post_id } = schedule;
|
||||
|
||||
if (category_id === CATEGORY_IDS.YOUTUBE && youtube_video_id) {
|
||||
const url = youtube_video_type === 'shorts'
|
||||
? `https://www.youtube.com/shorts/${youtube_video_id}`
|
||||
: `https://www.youtube.com/watch?v=${youtube_video_id}`;
|
||||
return {
|
||||
name: youtube_channel || 'YouTube',
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
if (category_id === CATEGORY_IDS.X && x_post_id) {
|
||||
return {
|
||||
name: '',
|
||||
url: `https://x.com/${config.x.defaultUsername}/status/${x_post_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 일정 포맷팅 (공통)
|
||||
* @param {object} rawSchedule - DB에서 조회한 원본 일정
|
||||
* @param {string[]} members - 멤버 이름 배열
|
||||
* @returns {object} 포맷된 일정 객체
|
||||
*/
|
||||
export function formatSchedule(rawSchedule, members = []) {
|
||||
return {
|
||||
id: rawSchedule.id,
|
||||
title: rawSchedule.title,
|
||||
datetime: buildDatetime(rawSchedule.date, rawSchedule.time),
|
||||
category: {
|
||||
id: rawSchedule.category_id,
|
||||
name: rawSchedule.category_name,
|
||||
color: rawSchedule.category_color,
|
||||
},
|
||||
source: buildSource(rawSchedule),
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 활동 멤버 수 조회
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @returns {number} 현재 활동 멤버 수
|
||||
*/
|
||||
async function getActiveMemberCount(db) {
|
||||
const [[{ count }]] = await db.query(
|
||||
'SELECT COUNT(*) as count FROM members WHERE is_former = 0'
|
||||
);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 맵 조회 (일정 ID → 멤버 이름 배열)
|
||||
* 전체 멤버인 경우 "프로미스나인"으로 대체
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {number[]} scheduleIds - 일정 ID 배열
|
||||
* @returns {object} { scheduleId: [memberName, ...] }
|
||||
*/
|
||||
export async function buildMemberMap(db, scheduleIds) {
|
||||
if (!scheduleIds || scheduleIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 현재 활동 멤버 수 조회
|
||||
const activeMemberCount = await getActiveMemberCount(db);
|
||||
|
||||
const [scheduleMembers] = await db.query(`
|
||||
SELECT sm.schedule_id, m.name
|
||||
FROM schedule_members sm
|
||||
JOIN members m ON sm.member_id = m.id
|
||||
WHERE sm.schedule_id IN (?) AND m.is_former = 0
|
||||
ORDER BY m.id
|
||||
`, [scheduleIds]);
|
||||
|
||||
const memberMap = {};
|
||||
for (const sm of scheduleMembers) {
|
||||
if (!memberMap[sm.schedule_id]) {
|
||||
memberMap[sm.schedule_id] = [];
|
||||
}
|
||||
memberMap[sm.schedule_id].push(sm.name);
|
||||
}
|
||||
|
||||
// 전체 멤버인 경우 "프로미스나인"으로 대체
|
||||
for (const scheduleId of Object.keys(memberMap)) {
|
||||
if (memberMap[scheduleId].length === activeMemberCount) {
|
||||
memberMap[scheduleId] = ['프로미스나인'];
|
||||
}
|
||||
}
|
||||
|
||||
return memberMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 목록 포맷팅 (공통)
|
||||
* @param {object[]} rawSchedules - DB에서 조회한 원본 일정 배열
|
||||
* @param {object} memberMap - 멤버 맵
|
||||
* @returns {object[]} 포맷된 일정 배열
|
||||
*/
|
||||
export function formatSchedules(rawSchedules, memberMap) {
|
||||
return rawSchedules.map(s => formatSchedule(s, memberMap[s.id] || []));
|
||||
}
|
||||
|
||||
// ==================== 카테고리 ====================
|
||||
|
||||
/**
|
||||
* 카테고리 목록 조회 (캐시 적용)
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {object} redis - Redis 클라이언트 (선택적)
|
||||
* @returns {array} 카테고리 목록
|
||||
*/
|
||||
export async function getCategories(db, redis = null) {
|
||||
const fetchCategories = async () => {
|
||||
const [categories] = await db.query(
|
||||
'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC'
|
||||
);
|
||||
return categories;
|
||||
};
|
||||
|
||||
if (redis) {
|
||||
return getOrSet(redis, cacheKeys.categories, fetchCategories, TTL.VERY_LONG);
|
||||
}
|
||||
return fetchCategories();
|
||||
}
|
||||
|
||||
// ==================== 일정 상세 ====================
|
||||
|
||||
/**
|
||||
* 일정 상세 조회
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {number} id - 일정 ID
|
||||
* @param {Function} getXProfile - X 프로필 조회 함수 (선택적)
|
||||
* @returns {object|null} 일정 상세 또는 null
|
||||
*/
|
||||
export async function getScheduleDetail(db, id, getXProfile = null) {
|
||||
const [schedules] = await db.query(`
|
||||
SELECT
|
||||
s.*,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
sy.channel_name as youtube_channel,
|
||||
sy.video_id as youtube_video_id,
|
||||
sy.video_type as youtube_video_type,
|
||||
sx.post_id as x_post_id,
|
||||
sx.content as x_content,
|
||||
sx.image_urls as x_image_urls
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const s = schedules[0];
|
||||
|
||||
// 현재 활동 멤버 수 조회
|
||||
const activeMemberCount = await getActiveMemberCount(db);
|
||||
|
||||
// 멤버 정보 조회 (탈퇴 멤버 제외)
|
||||
const [members] = await db.query(`
|
||||
SELECT m.id, m.name
|
||||
FROM schedule_members sm
|
||||
JOIN members m ON sm.member_id = m.id
|
||||
WHERE sm.schedule_id = ? AND m.is_former = 0
|
||||
ORDER BY m.id
|
||||
`, [id]);
|
||||
|
||||
// 전체 멤버인 경우 "프로미스나인"으로 대체
|
||||
const formattedMembers = members.length === activeMemberCount
|
||||
? [{ id: 0, name: '프로미스나인' }]
|
||||
: members;
|
||||
|
||||
// 공통 필드
|
||||
const result = {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
datetime: buildDatetime(s.date, s.time),
|
||||
category: {
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
},
|
||||
members: formattedMembers,
|
||||
createdAt: s.created_at,
|
||||
updatedAt: s.updated_at,
|
||||
};
|
||||
|
||||
// 카테고리별 추가 필드
|
||||
if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) {
|
||||
result.videoId = s.youtube_video_id;
|
||||
result.videoType = s.youtube_video_type;
|
||||
result.channelName = s.youtube_channel;
|
||||
result.videoUrl = s.youtube_video_type === 'shorts'
|
||||
? `https://www.youtube.com/shorts/${s.youtube_video_id}`
|
||||
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
|
||||
} else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) {
|
||||
const username = config.x.defaultUsername;
|
||||
result.postId = s.x_post_id;
|
||||
result.content = s.x_content || null;
|
||||
result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : [];
|
||||
result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`;
|
||||
|
||||
if (getXProfile) {
|
||||
const profile = await getXProfile(username);
|
||||
if (profile) {
|
||||
result.profile = {
|
||||
username: profile.username,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 일정 목록 조회 ====================
|
||||
|
||||
/** 일정 목록 조회용 공통 SQL */
|
||||
const SCHEDULE_LIST_SQL = `
|
||||
SELECT
|
||||
s.id,
|
||||
s.title,
|
||||
s.date,
|
||||
s.time,
|
||||
s.category_id,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
sy.channel_name as youtube_channel,
|
||||
sy.video_id as youtube_video_id,
|
||||
sy.video_type as youtube_video_type,
|
||||
sx.post_id as x_post_id
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
`;
|
||||
|
||||
/**
|
||||
* 월별 일정 조회 (생일 포함)
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {number} year - 연도
|
||||
* @param {number} month - 월
|
||||
* @returns {object} 날짜별로 그룹화된 일정
|
||||
* @returns {object} { schedules: [] }
|
||||
*/
|
||||
export async function getMonthlySchedules(db, year, month) {
|
||||
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const endDate = new Date(year, month, 0).toISOString().split('T')[0];
|
||||
|
||||
// 일정 조회 (YouTube, X 소스 정보 포함)
|
||||
const [schedules] = await db.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.title,
|
||||
s.date,
|
||||
s.time,
|
||||
s.category_id,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
sy.channel_name as youtube_channel,
|
||||
sy.video_id as youtube_video_id,
|
||||
sy.video_type as youtube_video_type,
|
||||
sx.post_id as x_post_id
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
WHERE s.date BETWEEN ? AND ?
|
||||
ORDER BY s.date ASC, s.time ASC
|
||||
`, [startDate, endDate]);
|
||||
// 일정 조회
|
||||
const [rawSchedules] = await db.query(
|
||||
`${SCHEDULE_LIST_SQL} WHERE s.date BETWEEN ? AND ? ORDER BY s.date ASC, s.time ASC`,
|
||||
[startDate, endDate]
|
||||
);
|
||||
|
||||
// 일정 멤버 조회
|
||||
const scheduleIds = schedules.map(s => s.id);
|
||||
let memberMap = {};
|
||||
// 멤버 맵 조회
|
||||
const memberMap = await buildMemberMap(db, rawSchedules.map(s => s.id));
|
||||
|
||||
if (scheduleIds.length > 0) {
|
||||
const [scheduleMembers] = await db.query(`
|
||||
SELECT sm.schedule_id, m.name
|
||||
FROM schedule_members sm
|
||||
JOIN members m ON sm.member_id = m.id
|
||||
WHERE sm.schedule_id IN (?)
|
||||
ORDER BY m.id
|
||||
`, [scheduleIds]);
|
||||
// 일정 포맷팅
|
||||
const schedules = formatSchedules(rawSchedules, memberMap);
|
||||
|
||||
for (const sm of scheduleMembers) {
|
||||
if (!memberMap[sm.schedule_id]) {
|
||||
memberMap[sm.schedule_id] = [];
|
||||
}
|
||||
memberMap[sm.schedule_id].push({ name: sm.name });
|
||||
}
|
||||
}
|
||||
|
||||
// 생일 조회
|
||||
// 생일 조회 및 추가
|
||||
const [birthdays] = await db.query(`
|
||||
SELECT m.id, m.name, m.name_en, m.birth_date,
|
||||
i.thumb_url as image_url
|
||||
|
|
@ -67,185 +315,53 @@ export async function getMonthlySchedules(db, year, month) {
|
|||
WHERE m.is_former = 0 AND MONTH(m.birth_date) = ?
|
||||
`, [month]);
|
||||
|
||||
// 날짜별로 그룹화
|
||||
const grouped = {};
|
||||
|
||||
// 일정 추가
|
||||
for (const s of schedules) {
|
||||
const dateKey = s.date instanceof Date
|
||||
? s.date.toISOString().split('T')[0]
|
||||
: s.date;
|
||||
|
||||
if (!grouped[dateKey]) {
|
||||
grouped[dateKey] = {
|
||||
categories: [],
|
||||
schedules: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 멤버 정보 (5명 이상이면 프로미스나인)
|
||||
const scheduleMembers = memberMap[s.id] || [];
|
||||
const members = scheduleMembers.length >= 5
|
||||
? [{ name: '프로미스나인' }]
|
||||
: scheduleMembers;
|
||||
|
||||
const schedule = {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
time: s.time,
|
||||
category: {
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
},
|
||||
members,
|
||||
};
|
||||
|
||||
// source 정보 추가
|
||||
if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) {
|
||||
const videoUrl = s.youtube_video_type === 'shorts'
|
||||
? `https://www.youtube.com/shorts/${s.youtube_video_id}`
|
||||
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
|
||||
schedule.source = {
|
||||
name: s.youtube_channel || 'YouTube',
|
||||
url: videoUrl,
|
||||
};
|
||||
} else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) {
|
||||
schedule.source = {
|
||||
name: '',
|
||||
url: `https://x.com/${config.x.defaultUsername}/status/${s.x_post_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
grouped[dateKey].schedules.push(schedule);
|
||||
|
||||
// 카테고리 카운트
|
||||
const existingCategory = grouped[dateKey].categories.find(c => c.id === s.category_id);
|
||||
if (existingCategory) {
|
||||
existingCategory.count++;
|
||||
} else {
|
||||
grouped[dateKey].categories.push({
|
||||
id: s.category_id,
|
||||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 생일 일정 추가
|
||||
for (const member of birthdays) {
|
||||
const birthDate = new Date(member.birth_date);
|
||||
const birthYear = birthDate.getFullYear();
|
||||
if (year < birthDate.getFullYear()) continue;
|
||||
|
||||
// 조회 연도가 생년보다 이전이면 스킵
|
||||
if (year < birthYear) continue;
|
||||
const birthdayDate = new Date(year, birthDate.getMonth(), birthDate.getDate());
|
||||
|
||||
const birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate());
|
||||
const dateKey = birthdayThisYear.toISOString().split('T')[0];
|
||||
|
||||
if (!grouped[dateKey]) {
|
||||
grouped[dateKey] = {
|
||||
categories: [],
|
||||
schedules: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 생일 카테고리
|
||||
const BIRTHDAY_CATEGORY = {
|
||||
id: CATEGORY_IDS.BIRTHDAY,
|
||||
name: '생일',
|
||||
color: '#f472b6',
|
||||
};
|
||||
|
||||
const birthdaySchedule = {
|
||||
schedules.push({
|
||||
id: `birthday-${member.id}`,
|
||||
title: `HAPPY ${member.name_en} DAY`,
|
||||
time: null,
|
||||
category: BIRTHDAY_CATEGORY,
|
||||
datetime: birthdayDate.toISOString().split('T')[0],
|
||||
category: {
|
||||
id: CATEGORY_IDS.BIRTHDAY,
|
||||
name: '생일',
|
||||
color: '#f472b6',
|
||||
},
|
||||
source: null,
|
||||
members: [member.name],
|
||||
is_birthday: true,
|
||||
member_name: member.name,
|
||||
member_image: member.image_url,
|
||||
};
|
||||
|
||||
grouped[dateKey].schedules.push(birthdaySchedule);
|
||||
|
||||
// 생일 카테고리 카운트
|
||||
const existingBirthdayCategory = grouped[dateKey].categories.find(c => c.id === CATEGORY_IDS.BIRTHDAY);
|
||||
if (existingBirthdayCategory) {
|
||||
existingBirthdayCategory.count++;
|
||||
} else {
|
||||
grouped[dateKey].categories.push({
|
||||
...BIRTHDAY_CATEGORY,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return grouped;
|
||||
// 날짜순 정렬
|
||||
schedules.sort((a, b) => a.datetime.localeCompare(b.datetime));
|
||||
|
||||
return { schedules };
|
||||
}
|
||||
|
||||
/**
|
||||
* 다가오는 일정 조회 (startDate부터 limit개)
|
||||
* 다가오는 일정 조회
|
||||
* @param {object} db - 데이터베이스 연결
|
||||
* @param {string} startDate - 시작 날짜
|
||||
* @param {number} limit - 조회 개수
|
||||
* @returns {array} 일정 목록
|
||||
* @returns {object} { schedules: [] }
|
||||
*/
|
||||
export async function getUpcomingSchedules(db, startDate, limit) {
|
||||
const [schedules] = await db.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.title,
|
||||
s.date,
|
||||
s.time,
|
||||
s.category_id,
|
||||
c.name as category_name,
|
||||
c.color as category_color
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
WHERE s.date >= ?
|
||||
ORDER BY s.date ASC, s.time ASC
|
||||
LIMIT ?
|
||||
`, [startDate, limit]);
|
||||
// 일정 조회
|
||||
const [rawSchedules] = await db.query(
|
||||
`${SCHEDULE_LIST_SQL} WHERE s.date >= ? ORDER BY s.date ASC, s.time ASC LIMIT ?`,
|
||||
[startDate, limit]
|
||||
);
|
||||
|
||||
// 멤버 정보 조회
|
||||
const scheduleIds = schedules.map(s => s.id);
|
||||
let memberMap = {};
|
||||
// 멤버 맵 조회
|
||||
const memberMap = await buildMemberMap(db, rawSchedules.map(s => s.id));
|
||||
|
||||
if (scheduleIds.length > 0) {
|
||||
const [scheduleMembers] = await db.query(`
|
||||
SELECT sm.schedule_id, m.name
|
||||
FROM schedule_members sm
|
||||
JOIN members m ON sm.member_id = m.id
|
||||
WHERE sm.schedule_id IN (?)
|
||||
ORDER BY m.id
|
||||
`, [scheduleIds]);
|
||||
// 일정 포맷팅
|
||||
const schedules = formatSchedules(rawSchedules, memberMap);
|
||||
|
||||
for (const sm of scheduleMembers) {
|
||||
if (!memberMap[sm.schedule_id]) {
|
||||
memberMap[sm.schedule_id] = [];
|
||||
}
|
||||
memberMap[sm.schedule_id].push({ name: sm.name });
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 포맷팅
|
||||
return schedules.map(s => {
|
||||
const scheduleMembers = memberMap[s.id] || [];
|
||||
const members = scheduleMembers.length >= 5
|
||||
? [{ name: '프로미스나인' }]
|
||||
: scheduleMembers;
|
||||
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
date: s.date,
|
||||
time: s.time,
|
||||
category_id: s.category_id,
|
||||
category_name: s.category_name,
|
||||
category_color: s.category_color,
|
||||
members,
|
||||
};
|
||||
});
|
||||
return { schedules };
|
||||
}
|
||||
|
|
|
|||
76
backend/src/utils/cache.js
Normal file
76
backend/src/utils/cache.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Redis 캐시 유틸리티
|
||||
*/
|
||||
|
||||
// 기본 TTL (초 단위)
|
||||
const DEFAULT_TTL = 300; // 5분
|
||||
|
||||
/**
|
||||
* 캐시에서 값을 가져오거나 없으면 함수 실행 후 캐시
|
||||
* @param {object} redis - Redis 클라이언트
|
||||
* @param {string} key - 캐시 키
|
||||
* @param {Function} fn - 데이터 조회 함수
|
||||
* @param {number} ttl - TTL (초), 기본 5분
|
||||
* @returns {Promise<any>} 캐시된 값 또는 새로 조회한 값
|
||||
*/
|
||||
export async function getOrSet(redis, key, fn, ttl = DEFAULT_TTL) {
|
||||
// 캐시 조회
|
||||
const cached = await redis.get(key);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// 캐시 미스: 데이터 조회 후 캐싱
|
||||
const data = await fn();
|
||||
if (data !== null && data !== undefined) {
|
||||
await redis.set(key, JSON.stringify(data), 'EX', ttl);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 무효화
|
||||
* @param {object} redis - Redis 클라이언트
|
||||
* @param {string|string[]} keys - 캐시 키 또는 키 배열
|
||||
*/
|
||||
export async function invalidate(redis, keys) {
|
||||
const keyList = Array.isArray(keys) ? keys : [keys];
|
||||
if (keyList.length > 0) {
|
||||
await redis.del(...keyList);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 패턴으로 캐시 무효화
|
||||
* @param {object} redis - Redis 클라이언트
|
||||
* @param {string} pattern - 키 패턴 (예: 'schedule:*')
|
||||
*/
|
||||
export async function invalidatePattern(redis, pattern) {
|
||||
const keys = await redis.keys(pattern);
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys);
|
||||
}
|
||||
}
|
||||
|
||||
// 캐시 키 생성 헬퍼
|
||||
export const cacheKeys = {
|
||||
// 멤버
|
||||
members: 'members:all',
|
||||
member: (name) => `member:${name}`,
|
||||
// 일정
|
||||
categories: 'categories:all',
|
||||
scheduleDetail: (id) => `schedule:${id}`,
|
||||
scheduleMonthly: (year, month) => `schedule:monthly:${year}:${month}`,
|
||||
// 앨범
|
||||
albums: 'albums:all',
|
||||
albumDetail: (id) => `album:${id}`,
|
||||
albumByName: (name) => `album:name:${name}`,
|
||||
};
|
||||
|
||||
// TTL 상수 (초)
|
||||
export const TTL = {
|
||||
SHORT: 60, // 1분
|
||||
MEDIUM: 300, // 5분
|
||||
LONG: 600, // 10분
|
||||
VERY_LONG: 3600, // 1시간
|
||||
};
|
||||
|
|
@ -12,6 +12,19 @@ services:
|
|||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
fromis9-frontend-dev:
|
||||
build: ./frontend-temp
|
||||
container_name: fromis9-frontend-dev
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
volumes:
|
||||
- ./frontend-temp:/app
|
||||
depends_on:
|
||||
- fromis9-backend
|
||||
networks:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
fromis9-backend:
|
||||
build: ./backend
|
||||
container_name: fromis9-backend
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ Base URL: `/api`
|
|||
}
|
||||
]
|
||||
```
|
||||
※ 멤버가 5명 이상이면 `[{ "name": "프로미스나인" }]` 반환
|
||||
※ 현재 활동 멤버 전원인 경우 `[{ "name": "프로미스나인" }]` 반환 (탈퇴 멤버 제외)
|
||||
|
||||
**검색 응답:**
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -45,36 +45,163 @@ fromis_9/
|
|||
│
|
||||
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
|
||||
│
|
||||
├── frontend/ # React 프론트엔드
|
||||
├── frontend/ # React 프론트엔드 (레거시, frontend-temp로 대체 예정)
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 클라이언트
|
||||
│ │ │ ├── index.js # fetchApi 유틸
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── public/ # 공개 API
|
||||
│ │ │ │ ├── albums.js
|
||||
│ │ │ │ ├── members.js
|
||||
│ │ │ │ └── schedules.js
|
||||
│ │ │ └── admin/ # 어드민 API
|
||||
│ │ │ ├── albums.js
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ ├── bots.js
|
||||
│ │ │ ├── categories.js
|
||||
│ │ │ ├── members.js
|
||||
│ │ │ ├── schedules.js
|
||||
│ │ │ ├── stats.js
|
||||
│ │ │ └── suggestions.js
|
||||
│ │ ├── components/ # 공통 컴포넌트
|
||||
│ │ │ └── common/
|
||||
│ │ │ ├── Lightbox.jsx # 이미지 라이트박스 (PC)
|
||||
│ │ │ └── LightboxIndicator.jsx
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── pc/ # PC 페이지
|
||||
│ │ │ └── mobile/ # 모바일 페이지
|
||||
│ │ ├── stores/ # Zustand 스토어
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── date.js # dayjs 기반 날짜 유틸리티
|
||||
│ │ └── App.jsx
|
||||
│ │ └── ...
|
||||
│ └── package.json
|
||||
│
|
||||
├── frontend-temp/ # React 프론트엔드 (신규, Strangler Fig 마이그레이션)
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 클라이언트 (공유)
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ ├── client.js # fetchApi, fetchAuthApi
|
||||
│ │ │ ├── albums.js
|
||||
│ │ │ ├── members.js
|
||||
│ │ │ ├── schedules.js
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ └── admin/ # 관리자 API
|
||||
│ │ │ ├── albums.js
|
||||
│ │ │ ├── members.js
|
||||
│ │ │ ├── schedules.js
|
||||
│ │ │ ├── categories.js
|
||||
│ │ │ ├── stats.js
|
||||
│ │ │ ├── bots.js
|
||||
│ │ │ └── suggestions.js
|
||||
│ │ │
|
||||
│ │ ├── hooks/ # 커스텀 훅 (공유)
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ ├── useAlbumData.js
|
||||
│ │ │ ├── useMemberData.js
|
||||
│ │ │ ├── useScheduleData.js
|
||||
│ │ │ ├── useScheduleSearch.js
|
||||
│ │ │ ├── useCalendar.js
|
||||
│ │ │ ├── useToast.js
|
||||
│ │ │ └── useAdminAuth.js
|
||||
│ │ │
|
||||
│ │ ├── stores/ # Zustand 스토어 (공유)
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ ├── useScheduleStore.js
|
||||
│ │ │ └── useAuthStore.js
|
||||
│ │ │
|
||||
│ │ ├── utils/ # 유틸리티 (공유)
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ ├── date.js
|
||||
│ │ │ └── format.js
|
||||
│ │ │
|
||||
│ │ ├── constants/
|
||||
│ │ │ └── index.js
|
||||
│ │ │
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ ├── common/ # 디바이스 무관 공통 컴포넌트
|
||||
│ │ │ │ ├── Loading.jsx
|
||||
│ │ │ │ ├── ErrorBoundary.jsx
|
||||
│ │ │ │ ├── Toast.jsx
|
||||
│ │ │ │ ├── Lightbox.jsx
|
||||
│ │ │ │ ├── LightboxIndicator.jsx
|
||||
│ │ │ │ ├── Tooltip.jsx
|
||||
│ │ │ │ └── ScrollToTop.jsx
|
||||
│ │ │ ├── pc/ # PC 레이아웃 컴포넌트
|
||||
│ │ │ │ ├── Layout.jsx
|
||||
│ │ │ │ ├── Header.jsx
|
||||
│ │ │ │ └── Footer.jsx
|
||||
│ │ │ ├── mobile/ # Mobile 레이아웃 컴포넌트
|
||||
│ │ │ │ ├── Layout.jsx
|
||||
│ │ │ │ └── MobileNav.jsx
|
||||
│ │ │ └── admin/ # 관리자 컴포넌트
|
||||
│ │ │ ├── AdminLayout.jsx
|
||||
│ │ │ ├── AdminHeader.jsx
|
||||
│ │ │ ├── ConfirmDialog.jsx
|
||||
│ │ │ ├── CustomDatePicker.jsx
|
||||
│ │ │ ├── CustomTimePicker.jsx
|
||||
│ │ │ └── NumberPicker.jsx
|
||||
│ │ │
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ │
|
||||
│ │ │ ├── home/
|
||||
│ │ │ │ ├── index.js # export { PCHome, MobileHome }
|
||||
│ │ │ │ ├── pc/
|
||||
│ │ │ │ │ └── Home.jsx
|
||||
│ │ │ │ └── mobile/
|
||||
│ │ │ │ └── Home.jsx
|
||||
│ │ │ │
|
||||
│ │ │ ├── members/
|
||||
│ │ │ │ ├── index.js
|
||||
│ │ │ │ ├── pc/
|
||||
│ │ │ │ │ └── Members.jsx
|
||||
│ │ │ │ └── mobile/
|
||||
│ │ │ │ └── Members.jsx
|
||||
│ │ │ │
|
||||
│ │ │ ├── album/
|
||||
│ │ │ │ ├── index.js
|
||||
│ │ │ │ ├── pc/
|
||||
│ │ │ │ │ ├── Album.jsx
|
||||
│ │ │ │ │ ├── AlbumDetail.jsx
|
||||
│ │ │ │ │ ├── AlbumGallery.jsx
|
||||
│ │ │ │ │ └── TrackDetail.jsx
|
||||
│ │ │ │ └── mobile/
|
||||
│ │ │ │ ├── Album.jsx
|
||||
│ │ │ │ ├── AlbumDetail.jsx
|
||||
│ │ │ │ ├── AlbumGallery.jsx
|
||||
│ │ │ │ └── TrackDetail.jsx
|
||||
│ │ │ │
|
||||
│ │ │ ├── schedule/
|
||||
│ │ │ │ ├── index.js
|
||||
│ │ │ │ ├── sections/ # 일정 상세 섹션 (PC 전용)
|
||||
│ │ │ │ │ ├── DefaultSection.jsx
|
||||
│ │ │ │ │ ├── XSection.jsx
|
||||
│ │ │ │ │ └── YoutubeSection.jsx
|
||||
│ │ │ │ ├── pc/
|
||||
│ │ │ │ │ ├── Schedule.jsx
|
||||
│ │ │ │ │ ├── ScheduleDetail.jsx
|
||||
│ │ │ │ │ └── Birthday.jsx
|
||||
│ │ │ │ └── mobile/
|
||||
│ │ │ │ ├── Schedule.jsx
|
||||
│ │ │ │ └── ScheduleDetail.jsx
|
||||
│ │ │ │
|
||||
│ │ │ ├── common/
|
||||
│ │ │ │ ├── pc/
|
||||
│ │ │ │ │ └── NotFound.jsx
|
||||
│ │ │ │ └── mobile/
|
||||
│ │ │ │ └── NotFound.jsx
|
||||
│ │ │ │
|
||||
│ │ │ └── admin/ # 관리자 페이지 (PC 전용)
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ ├── Login.jsx
|
||||
│ │ │ ├── Dashboard.jsx
|
||||
│ │ │ ├── members/
|
||||
│ │ │ │ ├── List.jsx
|
||||
│ │ │ │ └── Edit.jsx
|
||||
│ │ │ ├── albums/
|
||||
│ │ │ │ ├── List.jsx
|
||||
│ │ │ │ ├── Form.jsx
|
||||
│ │ │ │ └── Photos.jsx
|
||||
│ │ │ ├── schedules/
|
||||
│ │ │ │ ├── List.jsx
|
||||
│ │ │ │ ├── Form.jsx
|
||||
│ │ │ │ ├── YouTubeForm.jsx
|
||||
│ │ │ │ ├── XForm.jsx
|
||||
│ │ │ │ └── YouTubeEditForm.jsx
|
||||
│ │ │ ├── categories/
|
||||
│ │ │ │ └── List.jsx
|
||||
│ │ │ ├── bots/
|
||||
│ │ │ │ └── Manager.jsx
|
||||
│ │ │ └── dict/
|
||||
│ │ │ └── Manager.jsx
|
||||
│ │ │
|
||||
│ │ ├── App.jsx # BrowserView/MobileView 라우팅
|
||||
│ │ └── main.jsx
|
||||
│ │
|
||||
│ ├── vite.config.js
|
||||
│ ├── Dockerfile # 프론트엔드 컨테이너
|
||||
│ ├── tailwind.config.js
|
||||
│ ├── Dockerfile
|
||||
│ └── package.json
|
||||
│
|
||||
├── docker-compose.yml
|
||||
|
|
|
|||
273
docs/frontend-improvement.md
Normal file
273
docs/frontend-improvement.md
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# 일정 관리 페이지 개선 계획
|
||||
|
||||
## 대상 파일
|
||||
|
||||
| 파일 | 라인 수 | 역할 |
|
||||
|------|---------|------|
|
||||
| Schedules.jsx | 1159 | 일정 목록/검색 |
|
||||
| ScheduleForm.jsx | 765 | 일정 추가/수정 폼 |
|
||||
| ScheduleDict.jsx | 572 | 사전 관리 |
|
||||
| ScheduleBots.jsx | 570 | 봇 관리 |
|
||||
| ScheduleCategory.jsx | 466 | 카테고리 관리 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 공통 코드 중복 문제
|
||||
|
||||
### 1.1 colorMap / getColorStyle 중복
|
||||
|
||||
**현황:** 3개 파일에서 동일한 코드 반복
|
||||
|
||||
```javascript
|
||||
// Schedules.jsx:206-224
|
||||
// ScheduleForm.jsx:97-117
|
||||
// ScheduleCategory.jsx:24-36
|
||||
const colorMap = {
|
||||
blue: 'bg-blue-500',
|
||||
green: 'bg-green-500',
|
||||
// ...
|
||||
};
|
||||
|
||||
const getColorStyle = (color) => {
|
||||
if (!color) return { className: 'bg-gray-500' };
|
||||
if (color.startsWith('#')) {
|
||||
return { style: { backgroundColor: color } };
|
||||
}
|
||||
return { className: colorMap[color] || 'bg-gray-500' };
|
||||
};
|
||||
```
|
||||
|
||||
**개선안:**
|
||||
```
|
||||
utils/color.js 생성
|
||||
├── COLOR_MAP (상수)
|
||||
├── COLOR_OPTIONS (ScheduleCategory에서 사용하는 색상 옵션)
|
||||
└── getColorStyle(color) (함수)
|
||||
```
|
||||
|
||||
### 1.2 colorOptions 상수
|
||||
|
||||
**현황:** ScheduleCategory.jsx에만 있지만 확장성 고려
|
||||
|
||||
```javascript
|
||||
// ScheduleCategory.jsx:13-22
|
||||
const colorOptions = [
|
||||
{ id: 'blue', name: '파란색', bg: 'bg-blue-500', hex: '#3b82f6' },
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
**개선안:** `constants/colors.js` 또는 `utils/color.js`에 통합
|
||||
|
||||
---
|
||||
|
||||
## 2. 파일별 개선 사항
|
||||
|
||||
### 2.1 Schedules.jsx (1159줄)
|
||||
|
||||
#### 검색 관련 상태/로직 복잡
|
||||
|
||||
**현황:** 검색 관련 상태가 10개 이상, useEffect 5개
|
||||
|
||||
```javascript
|
||||
// 검색 관련 상태 (55-65줄)
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||
const [originalSearchQuery, setOriginalSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
```
|
||||
|
||||
**개선안:** `useScheduleSearch` 커스텀 훅 분리
|
||||
```javascript
|
||||
// hooks/pc/admin/useScheduleSearch.js
|
||||
export function useScheduleSearch() {
|
||||
// 검색 상태 및 로직 캡슐화
|
||||
return {
|
||||
searchInput, setSearchInput,
|
||||
suggestions, isLoadingSuggestions,
|
||||
handleSearch, handleSuggestionSelect,
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 달력 로직 분리 가능
|
||||
|
||||
**현황:** 달력 관련 계산이 컴포넌트 내부에 산재
|
||||
|
||||
```javascript
|
||||
// 161-181줄
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
// ...
|
||||
```
|
||||
|
||||
**개선안:** 기존 `utils/date.js`에 달력 헬퍼 함수 추가 또는 `useCalendar` 훅 생성
|
||||
|
||||
---
|
||||
|
||||
### 2.2 ScheduleForm.jsx (765줄)
|
||||
|
||||
#### fetchSchedule 함수 중복 설정
|
||||
|
||||
**현황:** formData를 두 번 설정 (비효율)
|
||||
|
||||
```javascript
|
||||
// 140-158줄: 첫 번째 setFormData
|
||||
setFormData({
|
||||
title: data.title || '',
|
||||
startDate: data.date ? formatDate(data.date) : '',
|
||||
// ...
|
||||
});
|
||||
|
||||
// 163-184줄: 두 번째 setFormData (기존 이미지 처리 시)
|
||||
if (data.images && data.images.length > 0) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
title: data.title || '', // 중복!
|
||||
// ...
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
**개선안:** 하나의 setFormData로 통합
|
||||
|
||||
```javascript
|
||||
const initialFormData = {
|
||||
title: data.title || '',
|
||||
startDate: data.date ? formatDate(data.date) : '',
|
||||
// ...
|
||||
images: data.images?.map((img) => ({ id: img.id, url: img.image_url })) || [],
|
||||
};
|
||||
setFormData(initialFormData);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 ScheduleDict.jsx (572줄)
|
||||
|
||||
#### generateId 일관성
|
||||
|
||||
**현황:** `generateId`를 useCallback으로 정의했지만, `parseDict` 내부에서는 인라인으로 같은 로직 사용
|
||||
|
||||
```javascript
|
||||
// 113-116줄
|
||||
const generateId = useCallback(
|
||||
() => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
[]
|
||||
);
|
||||
|
||||
// 128줄 (parseDict 내부)
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
```
|
||||
|
||||
**개선안:** `generateId`를 외부 유틸 함수로 분리하거나, parseDict에서 generateId 참조
|
||||
|
||||
---
|
||||
|
||||
### 2.4 ScheduleBots.jsx (570줄)
|
||||
|
||||
#### 인라인 컴포넌트 분리
|
||||
|
||||
**현황:** AnimatedNumber, XIcon, MeilisearchIcon이 파일 내부에 정의
|
||||
|
||||
```javascript
|
||||
// 42-65줄
|
||||
function AnimatedNumber({ value, className = '' }) { ... }
|
||||
|
||||
// 68-72줄
|
||||
const XIcon = ({ size = 20, fill = 'currentColor' }) => ( ... );
|
||||
|
||||
// 75-128줄
|
||||
const MeilisearchIcon = ({ size = 20 }) => ( ... );
|
||||
```
|
||||
|
||||
**개선안:**
|
||||
```
|
||||
components/common/
|
||||
├── AnimatedNumber.jsx (재사용 가능한 애니메이션 숫자)
|
||||
└── icons/
|
||||
├── XIcon.jsx
|
||||
└── MeilisearchIcon.jsx
|
||||
```
|
||||
|
||||
#### 봇 카드 컴포넌트 분리
|
||||
|
||||
**현황:** 봇 카드 렌더링이 414-559줄로 약 145줄
|
||||
|
||||
**개선안:**
|
||||
```javascript
|
||||
// components/pc/admin/bot/BotCard.jsx
|
||||
function BotCard({ bot, onToggle, onSync, syncing }) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.5 ScheduleCategory.jsx (466줄)
|
||||
|
||||
#### 모달 컴포넌트 인라인
|
||||
|
||||
**현황:** 카테고리 추가/수정 모달이 284-445줄로 약 160줄
|
||||
|
||||
**개선안:**
|
||||
```javascript
|
||||
// components/pc/admin/schedule/CategoryFormModal.jsx
|
||||
function CategoryFormModal({ isOpen, onClose, category, onSave }) {
|
||||
// 색상 선택, 폼 로직 포함
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 개선 우선순위
|
||||
|
||||
### Phase 1: 중복 코드 제거 (빠른 효과) ✅ 완료
|
||||
1. [x] `utils/color.js` 생성 - COLOR_MAP, COLOR_OPTIONS, getColorStyle 통합
|
||||
2. [x] 3개 파일에서 import로 교체
|
||||
- Schedules.jsx: 1159줄 → 1139줄 (20줄 감소)
|
||||
- ScheduleForm.jsx: 765줄 → 743줄 (22줄 감소)
|
||||
- ScheduleCategory.jsx: 466줄 → 441줄 (25줄 감소)
|
||||
|
||||
### Phase 2: 커스텀 훅 분리 (복잡도 감소) ✅ 완료
|
||||
1. [x] `useScheduleSearch` 훅 생성 - Schedules.jsx 검색 로직 분리
|
||||
- 검색어 자동완성, 무한 스크롤, 키보드 네비게이션 캡슐화
|
||||
- Schedules.jsx: 1139줄 → 1009줄 (130줄 감소)
|
||||
2. [ ] 달력 관련 로직 정리 (선택사항, 현재 규모 적절)
|
||||
|
||||
### Phase 3: 컴포넌트 분리 (재사용성) ✅ 완료
|
||||
1. [x] `AnimatedNumber` 공통 컴포넌트화 → components/common/AnimatedNumber.jsx (32줄)
|
||||
2. [x] `BotCard` 컴포넌트 분리 → components/pc/admin/bot/BotCard.jsx (233줄)
|
||||
3. [x] `CategoryFormModal` 컴포넌트 분리 → components/pc/admin/schedule/CategoryFormModal.jsx (195줄)
|
||||
4. [x] SVG 아이콘 분리 (XIcon, MeilisearchIcon) → BotCard.jsx에 포함
|
||||
- ScheduleBots.jsx: 570줄 → 339줄 (231줄 감소)
|
||||
- ScheduleCategory.jsx: 441줄 → 289줄 (152줄 감소)
|
||||
|
||||
### Phase 4: 코드 정리
|
||||
1. [ ] ScheduleForm.jsx - fetchSchedule 중복 제거
|
||||
2. [ ] ScheduleDict.jsx - generateId 일관성
|
||||
|
||||
---
|
||||
|
||||
## 4. 개선 결과
|
||||
|
||||
| 파일 | 개선 전 | 개선 후 | 감소 |
|
||||
|------|---------|---------|------|
|
||||
| Schedules.jsx | 1159줄 | 1009줄 | 150줄 |
|
||||
| ScheduleForm.jsx | 765줄 | 743줄 | 22줄 |
|
||||
| ScheduleDict.jsx | 572줄 | 572줄 | - |
|
||||
| ScheduleBots.jsx | 570줄 | 339줄 | 231줄 |
|
||||
| ScheduleCategory.jsx | 466줄 | 289줄 | 177줄 |
|
||||
| **합계** | **3532줄** | **2952줄** | **580줄** |
|
||||
|
||||
### 새로 생성된 파일
|
||||
| 파일 | 라인 수 | 역할 |
|
||||
|------|---------|------|
|
||||
| utils/color.js | 35줄 | 색상 상수/유틸 |
|
||||
| hooks/pc/admin/useScheduleSearch.js | 217줄 | 검색 로직 훅 |
|
||||
| components/common/AnimatedNumber.jsx | 32줄 | 숫자 애니메이션 |
|
||||
| components/pc/admin/bot/BotCard.jsx | 233줄 | 봇 카드 |
|
||||
| components/pc/admin/schedule/CategoryFormModal.jsx | 195줄 | 카테고리 폼 모달 |
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
# Express → Fastify 마이그레이션
|
||||
|
||||
## 개요
|
||||
|
||||
`backend-backup/` (Express) → `backend/` (Fastify)로 마이그레이션 완료
|
||||
|
||||
## 완료된 작업
|
||||
|
||||
### 서버 기반
|
||||
- [x] Fastify 앱 구조 (`src/app.js`, `src/server.js`)
|
||||
- [x] 플러그인 시스템 (`src/plugins/`)
|
||||
- db.js (MariaDB)
|
||||
- redis.js
|
||||
- auth.js (JWT)
|
||||
- meilisearch.js
|
||||
- scheduler.js (봇 스케줄러)
|
||||
|
||||
### API 라우트 (`src/routes/`)
|
||||
- [x] 인증 (`/api/auth`)
|
||||
- POST /login - 로그인
|
||||
- GET /verify - 토큰 검증
|
||||
- [x] 멤버 (`/api/members`)
|
||||
- GET / - 목록 조회
|
||||
- GET /:name - 상세 조회
|
||||
- PUT /:name - 수정 (이미지 업로드 포함)
|
||||
- [x] 앨범 (`/api/albums`)
|
||||
- GET / - 목록 조회
|
||||
- GET /:id - ID로 조회
|
||||
- GET /by-name/:name - 이름으로 조회
|
||||
- GET /by-name/:albumName/track/:trackTitle - 트랙 조회
|
||||
- POST / - 생성
|
||||
- PUT /:id - 수정
|
||||
- DELETE /:id - 삭제
|
||||
- 사진 관리 (`/api/albums/:id/photos`)
|
||||
- GET / - 목록
|
||||
- POST / - 업로드
|
||||
- PUT /:photoId - 수정
|
||||
- DELETE /:photoId - 삭제
|
||||
- 티저 관리 (`/api/albums/:id/teasers`)
|
||||
- GET / - 목록
|
||||
- POST / - 업로드
|
||||
- DELETE /:teaserId - 삭제
|
||||
- [x] 일정 (`/api/schedules`)
|
||||
- GET / - 월별 조회 (생일 포함)
|
||||
- GET /?search= - Meilisearch 검색
|
||||
- GET /:id - 상세 조회
|
||||
- DELETE /:id - 삭제
|
||||
- POST /sync-search - Meilisearch 동기화
|
||||
- [x] 추천 검색어 (`/api/schedules/suggestions`)
|
||||
- GET / - 추천 검색어 조회
|
||||
- GET /popular - 인기 검색어 조회
|
||||
- POST /save - 검색어 저장
|
||||
- GET /dict - 사용자 사전 조회 (관리자)
|
||||
- PUT /dict - 사용자 사전 저장 (관리자)
|
||||
- [x] 통계 (`/api/stats`)
|
||||
- GET / - 대시보드 통계
|
||||
|
||||
### 관리자 API (`src/routes/admin/`)
|
||||
- [x] 봇 관리 (`/api/admin/bots`)
|
||||
- GET / - 봇 목록
|
||||
- POST /:id/start - 봇 시작
|
||||
- POST /:id/stop - 봇 정지
|
||||
- POST /:id/sync-all - 전체 동기화
|
||||
- GET /quota-warning - 할당량 경고 조회
|
||||
- DELETE /quota-warning - 할당량 경고 해제
|
||||
- [x] YouTube 관리 (`/api/admin/youtube`)
|
||||
- GET /video-info - 영상 정보 조회
|
||||
- POST /schedule - 일정 저장
|
||||
- PUT /schedule/:id - 일정 수정
|
||||
- [x] X 관리 (`/api/admin/x`)
|
||||
- GET /post-info - 게시글 정보 조회
|
||||
- POST /schedule - 일정 저장
|
||||
|
||||
### 서비스 (`src/services/`)
|
||||
- [x] YouTube 봇 (`services/youtube/`)
|
||||
- 영상 자동 수집
|
||||
- 채널별 필터링 (제목 필터, 멤버 추출)
|
||||
- [x] X(Twitter) 봇 (`services/x/`)
|
||||
- Nitter 스크래핑
|
||||
- 이미지 URL 추출
|
||||
- [x] Meilisearch 검색 (`services/meilisearch/`)
|
||||
- 일정 검색
|
||||
- 전체 동기화
|
||||
- [x] 추천 검색어 (`services/suggestions/`)
|
||||
- 형태소 분석 (kiwi-nlp)
|
||||
- bi-gram 빈도
|
||||
- 초성 검색
|
||||
- 사용자 사전 관리
|
||||
- [x] 이미지 업로드 (`services/image.js`)
|
||||
- 앨범 커버
|
||||
- 멤버 이미지
|
||||
- 앨범 사진/티저
|
||||
|
||||
## 남은 작업 (미구현)
|
||||
|
||||
### 일반 일정 CRUD
|
||||
- [ ] POST /api/schedules - 일정 생성 (일반)
|
||||
- [ ] PUT /api/schedules/:id - 일정 수정 (일반)
|
||||
|
||||
※ 현재는 YouTube/X 전용 일정 생성 API만 구현됨
|
||||
|
||||
### 카테고리 관리
|
||||
- [ ] POST /api/schedule-categories - 생성
|
||||
- [ ] PUT /api/schedule-categories/:id - 수정
|
||||
- [ ] DELETE /api/schedule-categories/:id - 삭제
|
||||
- [ ] PUT /api/schedule-categories-order - 순서 변경
|
||||
|
||||
※ GET은 구현됨 (목록 조회)
|
||||
|
||||
### 기타
|
||||
- [ ] GET /api/kakao/places - 카카오 장소 검색 프록시
|
||||
|
||||
## 파일 비교표
|
||||
|
||||
| Express (backend-backup) | Fastify (backend) | 상태 |
|
||||
|--------------------------|-------------------|------|
|
||||
| routes/admin.js (로그인) | routes/auth.js | 완료 |
|
||||
| routes/admin.js (앨범 CRUD) | routes/albums/index.js | 완료 |
|
||||
| routes/admin.js (사진/티저) | routes/albums/photos.js, teasers.js | 완료 |
|
||||
| routes/admin.js (멤버 수정) | routes/members/index.js | 완료 |
|
||||
| routes/admin.js (일정 삭제) | routes/schedules/index.js | 완료 |
|
||||
| routes/admin.js (일정 생성/수정) | - | 미완료 |
|
||||
| routes/admin.js (카테고리 CUD) | - | 미완료 |
|
||||
| routes/admin.js (봇 관리) | routes/admin/bots.js | 완료 |
|
||||
| routes/admin.js (할당량) | routes/admin/bots.js | 완료 |
|
||||
| routes/admin.js (카카오) | - | 미완료 |
|
||||
| - | routes/admin/youtube.js | 신규 |
|
||||
| - | routes/admin/x.js | 신규 |
|
||||
| routes/albums.js | routes/albums/index.js | 완료 |
|
||||
| routes/members.js | routes/members/index.js | 완료 |
|
||||
| routes/schedules.js | routes/schedules/index.js | 완료 |
|
||||
| routes/stats.js | routes/stats/index.js | 완료 |
|
||||
| services/youtube-bot.js | services/youtube/ | 완료 |
|
||||
| services/youtube-scheduler.js | plugins/scheduler.js | 완료 |
|
||||
| services/x-bot.js | services/x/ | 완료 |
|
||||
| services/meilisearch.js | services/meilisearch/ | 완료 |
|
||||
| services/meilisearch-bot.js | services/meilisearch/ | 완료 |
|
||||
| services/suggestions.js | services/suggestions/ | 완료 |
|
||||
|
||||
## 참고 사항
|
||||
|
||||
- 기존 Express 코드는 `backend-backup/` 폴더에 보존
|
||||
- 마이그레이션 시 기존 코드 참조하여 동일 기능 구현
|
||||
- DB 스키마 변경 사항:
|
||||
- `tracks` → `album_tracks` (이름 변경)
|
||||
- `venues` → `concert_venues` (이름 변경)
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
# Backend Refactoring Plan
|
||||
|
||||
백엔드 코드 품질 개선을 위한 리팩토링 계획서
|
||||
|
||||
## 완료된 작업
|
||||
|
||||
### 1단계: 설정 통합 (config 정리) ✅ 완료
|
||||
- [x] 카테고리 ID 상수 통합 (`CATEGORY_IDS`)
|
||||
|
||||
**수정된 파일:**
|
||||
- `src/config/index.js` - `CATEGORY_IDS` 상수 추가
|
||||
- `src/routes/admin/youtube.js` - config에서 import
|
||||
- `src/routes/admin/x.js` - config에서 import
|
||||
- `src/routes/schedules/index.js` - 하드코딩된 2, 3, 8 → 상수로 변경
|
||||
|
||||
---
|
||||
|
||||
### 2단계: N+1 쿼리 최적화 ✅ 완료
|
||||
- [x] 앨범 목록 조회 시 트랙 한 번에 조회로 변경
|
||||
- [x] 스케줄 멤버 조회 - 이미 최적화됨 (확인 완료)
|
||||
|
||||
**수정된 파일:**
|
||||
- `src/routes/albums/index.js` - GET /api/albums에서 트랙 조회 최적화
|
||||
|
||||
---
|
||||
|
||||
### 3단계: 서비스 레이어 분리 ✅ 완료
|
||||
- [x] `src/services/album.js` 생성
|
||||
- [x] `src/services/schedule.js` 생성
|
||||
- [x] 라우트에서 서비스 호출로 변경
|
||||
|
||||
---
|
||||
|
||||
### 4단계: 에러 처리 통일 ✅ 완료
|
||||
- [x] 에러 응답 유틸리티 생성 (`src/utils/error.js`)
|
||||
- [x] `reply.status()` → `reply.code()` 통일
|
||||
|
||||
---
|
||||
|
||||
### 5단계: 중복 코드 제거 ✅ 완료
|
||||
- [x] 스케줄러 상태 업데이트 로직 통합 (handleSyncResult 함수)
|
||||
- [x] youtube/index.js 하드코딩된 카테고리 ID → config 사용
|
||||
|
||||
---
|
||||
|
||||
## 추가 작업 목록
|
||||
|
||||
### 6단계: 매직 넘버 config 이동 ✅ 완료
|
||||
- [x] 이미지 크기/품질 설정 (`services/image.js`)
|
||||
- [x] X 기본 사용자명 (`routes/admin/x.js`, `routes/schedules/index.js`, `services/schedule.js`)
|
||||
- [x] Meilisearch 최소 점수 (`services/meilisearch/index.js`)
|
||||
|
||||
**수정된 파일:**
|
||||
- `src/config/index.js` - `image`, `x`, `meilisearch.minScore` 추가
|
||||
- `src/services/image.js` - config에서 이미지 크기/품질 참조
|
||||
- `src/services/meilisearch/index.js` - config에서 minScore 참조
|
||||
- `src/routes/admin/x.js` - config에서 defaultUsername 참조
|
||||
- `src/routes/schedules/index.js` - config에서 defaultUsername 참조
|
||||
- `src/services/schedule.js` - config에서 defaultUsername 참조
|
||||
|
||||
---
|
||||
|
||||
### 7단계: 순차 쿼리 → 병렬 처리 ✅ 완료
|
||||
- [x] `services/album.js` getAlbumDetails - tracks, teasers, photos 병렬 조회
|
||||
- [x] `routes/albums/photos.js` - 멤버 INSERT 배치 처리
|
||||
|
||||
**수정된 파일:**
|
||||
- `src/services/album.js` - Promise.all로 3개 쿼리 병렬 실행
|
||||
- `src/routes/albums/photos.js` - for loop → VALUES ? 배치 INSERT
|
||||
|
||||
---
|
||||
|
||||
### 8단계: meilisearch 카테고리 ID 상수화 ✅ 완료
|
||||
- [x] `services/meilisearch/index.js` - 하드코딩된 2, 3 → CATEGORY_IDS 사용
|
||||
|
||||
**수정된 파일:**
|
||||
- `src/services/meilisearch/index.js` - CATEGORY_IDS.YOUTUBE, CATEGORY_IDS.X 사용
|
||||
|
||||
---
|
||||
|
||||
### 9단계: 응답 형식 통일 ✅ 완료
|
||||
- [x] `routes/schedules/suggestions.js` - `{success, message}` → `{error}` 또는 `{message}` 형식으로 통일
|
||||
|
||||
**수정된 파일:**
|
||||
- `src/routes/schedules/suggestions.js` - 응답 형식 통일
|
||||
|
||||
---
|
||||
|
||||
### 10단계: 로거 통일 ✅ 완료
|
||||
- [x] `src/utils/logger.js` 생성
|
||||
- [x] 모든 `console.error/log` → logger 또는 fastify.log 사용
|
||||
|
||||
**수정된 파일:**
|
||||
- `src/utils/logger.js` - 로거 유틸리티 생성 (createLogger)
|
||||
- `src/services/image.js` - logger 사용
|
||||
- `src/services/meilisearch/index.js` - logger 사용
|
||||
- `src/services/suggestions/index.js` - logger 사용
|
||||
- `src/services/suggestions/morpheme.js` - logger 사용
|
||||
- `src/routes/albums/photos.js` - fastify.log 사용
|
||||
- `src/routes/schedules/index.js` - fastify.log 사용
|
||||
- `src/routes/schedules/suggestions.js` - fastify.log 사용
|
||||
|
||||
---
|
||||
|
||||
### 11단계: 대형 핸들러 분리 ✅ 완료
|
||||
- [x] `routes/albums/index.js` POST/PUT/DELETE → 서비스 함수로 분리
|
||||
- [ ] `routes/albums/photos.js` POST - SSE 스트리밍으로 인해 분리 보류
|
||||
|
||||
**수정된 파일:**
|
||||
- `src/services/album.js` - createAlbum, updateAlbum, deleteAlbum, insertTracks 추가
|
||||
- `src/routes/albums/index.js` - 서비스 함수 호출로 변경 (80줄 감소)
|
||||
|
||||
---
|
||||
|
||||
### 12단계: 트랜잭션 헬퍼 추상화 ✅ 완료
|
||||
- [x] `src/utils/transaction.js` 생성 - withTransaction 함수
|
||||
- [x] 반복되는 트랜잭션 패턴 추상화 적용
|
||||
|
||||
**수정된 파일:**
|
||||
- `src/utils/transaction.js` - 트랜잭션 헬퍼 유틸리티 생성
|
||||
- `src/services/album.js` - createAlbum, updateAlbum, deleteAlbum에 withTransaction 적용
|
||||
- `src/routes/albums/photos.js` - DELETE 핸들러에 withTransaction 적용
|
||||
- `src/routes/albums/teasers.js` - DELETE 핸들러에 withTransaction 적용
|
||||
|
||||
---
|
||||
|
||||
### 13단계: Swagger/OpenAPI 문서화 개선 ✅ 완료
|
||||
- [x] `src/schemas/index.js` 생성 - 공통 스키마 정의
|
||||
- [x] `src/app.js` - Swagger components에 스키마 등록
|
||||
- [x] 태그 추가 (admin/youtube, admin/x, admin/bots)
|
||||
|
||||
**수정된 파일:**
|
||||
- `src/schemas/index.js` - JSON Schema 정의 (Error, Success, Album, Schedule 등)
|
||||
- `src/app.js` - Swagger 설정에 스키마 컴포넌트 추가
|
||||
|
||||
---
|
||||
|
||||
### 14단계: 입력 검증 강화 (JSON Schema) ✅ 완료
|
||||
- [x] 라우트에 params, querystring, body, response 스키마 추가
|
||||
- [x] 상세 description 추가로 API 문서 품질 향상
|
||||
|
||||
**수정된 파일:**
|
||||
- `src/routes/albums/index.js` - GET/POST/PUT/DELETE 스키마 추가
|
||||
- `src/routes/schedules/index.js` - 검색/조회/삭제 스키마 추가
|
||||
- `src/routes/admin/youtube.js` - 영상 조회/일정 등록/수정 스키마 추가
|
||||
- `src/routes/admin/x.js` - 게시글 조회/일정 등록 스키마 추가
|
||||
- `src/routes/admin/bots.js` - 봇 관리 스키마 추가
|
||||
|
||||
---
|
||||
|
||||
### 15단계: 스키마 파일 분리 ✅ 완료
|
||||
- [x] 단일 스키마 파일을 도메인별로 분리
|
||||
- [x] Re-export 패턴으로 기존 import 호환성 유지
|
||||
|
||||
**생성된 파일:**
|
||||
- `src/schemas/common.js` - 공통 스키마 (errorResponse, successResponse, paginationQuery, idParam)
|
||||
- `src/schemas/album.js` - 앨범 관련 스키마
|
||||
- `src/schemas/schedule.js` - 일정 관련 스키마
|
||||
- `src/schemas/admin.js` - 관리자 API 스키마 (YouTube, X)
|
||||
- `src/schemas/member.js` - 멤버 스키마
|
||||
- `src/schemas/auth.js` - 인증 스키마
|
||||
- `src/schemas/index.js` - 모든 스키마 re-export
|
||||
|
||||
---
|
||||
|
||||
## 진행 상황
|
||||
|
||||
| 단계 | 작업 | 상태 |
|
||||
|------|------|------|
|
||||
| 1단계 | 설정 통합 | ✅ 완료 |
|
||||
| 2단계 | N+1 쿼리 최적화 | ✅ 완료 |
|
||||
| 3단계 | 서비스 레이어 분리 | ✅ 완료 |
|
||||
| 4단계 | 에러 처리 통일 | ✅ 완료 |
|
||||
| 5단계 | 중복 코드 제거 | ✅ 완료 |
|
||||
| 6단계 | 매직 넘버 config 이동 | ✅ 완료 |
|
||||
| 7단계 | 순차→병렬 쿼리 | ✅ 완료 |
|
||||
| 8단계 | meilisearch 카테고리 ID | ✅ 완료 |
|
||||
| 9단계 | 응답 형식 통일 | ✅ 완료 |
|
||||
| 10단계 | 로거 통일 | ✅ 완료 |
|
||||
| 11단계 | 대형 핸들러 분리 | ✅ 완료 |
|
||||
| 12단계 | 트랜잭션 헬퍼 추상화 | ✅ 완료 |
|
||||
| 13단계 | Swagger/OpenAPI 문서화 | ✅ 완료 |
|
||||
| 14단계 | 입력 검증 강화 (JSON Schema) | ✅ 완료 |
|
||||
| 15단계 | 스키마 파일 분리 | ✅ 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 참고사항
|
||||
|
||||
- 각 단계별로 커밋 후 다음 단계 진행
|
||||
- 기존 API 응답 형식은 유지
|
||||
- 프론트엔드 수정 불필요하도록 진행
|
||||
- API 문서는 `/docs`에서 확인 가능 (Scalar API Reference)
|
||||
4
frontend-temp/Dockerfile
Normal file
4
frontend-temp/Dockerfile
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# 개발 모드
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"]
|
||||
22
frontend-temp/index.html
Normal file
22
frontend-temp/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<title>fromis_9 - 프로미스나인</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
as="style"
|
||||
crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2106
frontend-temp/package-lock.json
generated
Normal file
2106
frontend-temp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
44
frontend-temp/package.json
Normal file
44
frontend-temp/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "fromis9-frontend",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^11.0.8",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.2.0",
|
||||
"react-calendar": "^6.0.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-infinite-scroll-component": "^6.1.1",
|
||||
"react-intersection-observer": "^10.0.0",
|
||||
"react-ios-time-picker": "^0.2.2",
|
||||
"react-linkify": "^1.0.0-alpha",
|
||||
"react-photo-album": "^3.4.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-window": "^2.2.3",
|
||||
"swiper": "^12.0.3",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
6
frontend-temp/postcss.config.js
Normal file
6
frontend-temp/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
frontend-temp/public/favicon.ico
Normal file
BIN
frontend-temp/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
193
frontend-temp/src/App.jsx
Normal file
193
frontend-temp/src/App.jsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { BrowserView, MobileView } from 'react-device-detect';
|
||||
|
||||
// 공통 컴포넌트
|
||||
import { ScrollToTop } from '@/components/common';
|
||||
|
||||
// PC 레이아웃
|
||||
import { Layout as PCLayout } from '@/components/pc/public';
|
||||
|
||||
// Mobile 레이아웃
|
||||
import { Layout as MobileLayout } from '@/components/mobile';
|
||||
|
||||
// PC 공개 페이지
|
||||
import PCHome from '@/pages/pc/public/home/Home';
|
||||
import PCMembers from '@/pages/pc/public/members/Members';
|
||||
import PCSchedule from '@/pages/pc/public/schedule/Schedule';
|
||||
import PCScheduleDetail from '@/pages/pc/public/schedule/ScheduleDetail';
|
||||
import PCBirthday from '@/pages/pc/public/schedule/Birthday';
|
||||
import PCAlbum from '@/pages/pc/public/album/Album';
|
||||
import PCAlbumDetail from '@/pages/pc/public/album/AlbumDetail';
|
||||
import PCTrackDetail from '@/pages/pc/public/album/TrackDetail';
|
||||
import PCAlbumGallery from '@/pages/pc/public/album/AlbumGallery';
|
||||
import PCNotFound from '@/pages/pc/public/common/NotFound';
|
||||
|
||||
// PC 관리자 페이지
|
||||
import AdminLogin from '@/pages/pc/admin/login/Login';
|
||||
import AdminDashboard from '@/pages/pc/admin/dashboard/Dashboard';
|
||||
import AdminMembers from '@/pages/pc/admin/members/Members';
|
||||
import AdminMemberEdit from '@/pages/pc/admin/members/MemberEdit';
|
||||
import AdminAlbums from '@/pages/pc/admin/albums/Albums';
|
||||
import AdminAlbumForm from '@/pages/pc/admin/albums/AlbumForm';
|
||||
import AdminAlbumPhotos from '@/pages/pc/admin/albums/AlbumPhotos';
|
||||
import AdminSchedules from '@/pages/pc/admin/schedules/Schedules';
|
||||
import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm';
|
||||
import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form';
|
||||
import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm';
|
||||
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
||||
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
||||
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
||||
import AdminNotFound from '@/pages/pc/admin/common/NotFound';
|
||||
|
||||
// Mobile 페이지
|
||||
import MobileHome from '@/pages/mobile/home/Home';
|
||||
import MobileMembers from '@/pages/mobile/members/Members';
|
||||
import MobileSchedule from '@/pages/mobile/schedule/Schedule';
|
||||
import MobileScheduleDetail from '@/pages/mobile/schedule/ScheduleDetail';
|
||||
import MobileBirthday from '@/pages/mobile/schedule/Birthday';
|
||||
import MobileAlbum from '@/pages/mobile/album/Album';
|
||||
import MobileAlbumDetail from '@/pages/mobile/album/AlbumDetail';
|
||||
import MobileTrackDetail from '@/pages/mobile/album/TrackDetail';
|
||||
import MobileAlbumGallery from '@/pages/mobile/album/AlbumGallery';
|
||||
import MobileNotFound from '@/pages/mobile/common/NotFound';
|
||||
|
||||
/**
|
||||
* PC 환경에서 body에 클래스 추가하는 래퍼
|
||||
*/
|
||||
function PCWrapper({ children }) {
|
||||
useEffect(() => {
|
||||
document.body.classList.add('is-pc');
|
||||
return () => document.body.classList.remove('is-pc');
|
||||
}, []);
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로미스나인 팬사이트 메인 앱
|
||||
* react-device-detect를 사용한 PC/Mobile 분리
|
||||
*/
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<ScrollToTop />
|
||||
|
||||
{/* PC 뷰 */}
|
||||
<BrowserView>
|
||||
<PCWrapper>
|
||||
<Routes>
|
||||
{/* 관리자 페이지 (자체 레이아웃 사용) */}
|
||||
<Route path="/admin" element={<AdminLogin />} />
|
||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||
<Route path="/admin/members" element={<AdminMembers />} />
|
||||
<Route path="/admin/members/:name/edit" element={<AdminMemberEdit />} />
|
||||
<Route path="/admin/albums" element={<AdminAlbums />} />
|
||||
<Route path="/admin/albums/new" element={<AdminAlbumForm />} />
|
||||
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
||||
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
||||
<Route path="/admin/schedule" element={<AdminSchedules />} />
|
||||
<Route path="/admin/schedule/new" element={<AdminScheduleFormPage />} />
|
||||
<Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} />
|
||||
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
||||
<Route path="/admin/schedule/:id/edit/youtube" element={<AdminYouTubeEditForm />} />
|
||||
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
||||
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
||||
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
||||
{/* 관리자 404 페이지 */}
|
||||
<Route path="/admin/*" element={<AdminNotFound />} />
|
||||
|
||||
{/* 일반 페이지 (레이아웃 포함) */}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<PCLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<PCHome />} />
|
||||
<Route path="/members" element={<PCMembers />} />
|
||||
<Route path="/schedule" element={<PCSchedule />} />
|
||||
<Route path="/schedule/:id" element={<PCScheduleDetail />} />
|
||||
<Route path="/birthday/:memberName/:year" element={<PCBirthday />} />
|
||||
<Route path="/album" element={<PCAlbum />} />
|
||||
<Route path="/album/:name" element={<PCAlbumDetail />} />
|
||||
<Route path="/album/:name/track/:trackTitle" element={<PCTrackDetail />} />
|
||||
<Route path="/album/:name/gallery" element={<PCAlbumGallery />} />
|
||||
{/* 404 페이지 */}
|
||||
<Route path="*" element={<PCNotFound />} />
|
||||
</Routes>
|
||||
</PCLayout>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</PCWrapper>
|
||||
</BrowserView>
|
||||
|
||||
{/* Mobile 뷰 */}
|
||||
<MobileView>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<MobileLayout>
|
||||
<MobileHome />
|
||||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/members"
|
||||
element={
|
||||
<MobileLayout pageTitle="멤버" noShadow>
|
||||
<MobileMembers />
|
||||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/schedule"
|
||||
element={
|
||||
<MobileLayout pageTitle="일정" useCustomLayout>
|
||||
<MobileSchedule />
|
||||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
<Route path="/schedule/:id" element={<MobileScheduleDetail />} />
|
||||
<Route path="/birthday/:memberName/:year" element={<MobileBirthday />} />
|
||||
<Route
|
||||
path="/album"
|
||||
element={
|
||||
<MobileLayout pageTitle="앨범">
|
||||
<MobileAlbum />
|
||||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/album/:name"
|
||||
element={
|
||||
<MobileLayout pageTitle="앨범">
|
||||
<MobileAlbumDetail />
|
||||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/album/:name/track/:trackTitle"
|
||||
element={
|
||||
<MobileLayout pageTitle="곡 상세">
|
||||
<MobileTrackDetail />
|
||||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/album/:name/gallery"
|
||||
element={
|
||||
<MobileLayout pageTitle="앨범">
|
||||
<MobileAlbumGallery />
|
||||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
{/* 404 페이지 */}
|
||||
<Route path="*" element={<MobileNotFound />} />
|
||||
</Routes>
|
||||
</MobileView>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
97
frontend-temp/src/api/admin/albums.js
Normal file
97
frontend-temp/src/api/admin/albums.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* 관리자 앨범 API
|
||||
*/
|
||||
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
||||
|
||||
/**
|
||||
* 앨범 목록 조회
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getAlbums() {
|
||||
return fetchAuthApi('/albums');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 상세 조회
|
||||
* @param {number} id - 앨범 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getAlbum(id) {
|
||||
return fetchAuthApi(`/albums/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 생성
|
||||
* @param {FormData} formData - 앨범 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function createAlbum(formData) {
|
||||
return fetchFormData('/albums', formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 수정
|
||||
* @param {number} id - 앨범 ID
|
||||
* @param {FormData} formData - 앨범 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function updateAlbum(id, formData) {
|
||||
return fetchFormData(`/albums/${id}`, formData, 'PUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 삭제
|
||||
* @param {number} id - 앨범 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteAlbum(id) {
|
||||
return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 사진 목록 조회
|
||||
* @param {number} albumId - 앨범 ID
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getAlbumPhotos(albumId) {
|
||||
return fetchAuthApi(`/albums/${albumId}/photos`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 사진 업로드
|
||||
* @param {number} albumId - 앨범 ID
|
||||
* @param {FormData} formData - 사진 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function uploadAlbumPhotos(albumId, formData) {
|
||||
return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 사진 삭제
|
||||
* @param {number} albumId - 앨범 ID
|
||||
* @param {number} photoId - 사진 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteAlbumPhoto(albumId, photoId) {
|
||||
return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 티저 목록 조회
|
||||
* @param {number} albumId - 앨범 ID
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getAlbumTeasers(albumId) {
|
||||
return fetchAuthApi(`/albums/${albumId}/teasers`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 티저 삭제
|
||||
* @param {number} albumId - 앨범 ID
|
||||
* @param {number} teaserId - 티저 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteAlbumTeaser(albumId, teaserId) {
|
||||
return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' });
|
||||
}
|
||||
37
frontend-temp/src/api/admin/auth.js
Normal file
37
frontend-temp/src/api/admin/auth.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* 관리자 인증 API
|
||||
*/
|
||||
import { fetchApi, fetchAuthApi } from '@/api/client';
|
||||
|
||||
/**
|
||||
* 로그인
|
||||
* @param {string} username - 사용자명
|
||||
* @param {string} password - 비밀번호
|
||||
* @returns {Promise<{token: string, user: object}>}
|
||||
*/
|
||||
export async function login(username, password) {
|
||||
return fetchApi('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 검증
|
||||
* @returns {Promise<{valid: boolean, user: object}>}
|
||||
*/
|
||||
export async function verifyToken() {
|
||||
return fetchAuthApi('/auth/verify');
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경
|
||||
* @param {string} currentPassword - 현재 비밀번호
|
||||
* @param {string} newPassword - 새 비밀번호
|
||||
*/
|
||||
export async function changePassword(currentPassword, newPassword) {
|
||||
return fetchAuthApi('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
}
|
||||
55
frontend-temp/src/api/admin/bots.js
Normal file
55
frontend-temp/src/api/admin/bots.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* 관리자 봇 관리 API
|
||||
*/
|
||||
import { fetchAuthApi } from '@/api/client';
|
||||
|
||||
/**
|
||||
* 봇 목록 조회
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getBots() {
|
||||
return fetchAuthApi('/admin/bots');
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 시작
|
||||
* @param {string} id - 봇 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function startBot(id) {
|
||||
return fetchAuthApi(`/admin/bots/${id}/start`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 정지
|
||||
* @param {string} id - 봇 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function stopBot(id) {
|
||||
return fetchAuthApi(`/admin/bots/${id}/stop`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 전체 동기화
|
||||
* @param {string} id - 봇 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function syncAllVideos(id) {
|
||||
return fetchAuthApi(`/admin/bots/${id}/sync-all`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 할당량 경고 조회
|
||||
* @returns {Promise<{warning: boolean, message: string}>}
|
||||
*/
|
||||
export async function getQuotaWarning() {
|
||||
return fetchAuthApi('/admin/bots/quota-warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* 할당량 경고 해제
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function dismissQuotaWarning() {
|
||||
return fetchAuthApi('/admin/bots/quota-warning', { method: 'DELETE' });
|
||||
}
|
||||
60
frontend-temp/src/api/admin/categories.js
Normal file
60
frontend-temp/src/api/admin/categories.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* 관리자 카테고리 API
|
||||
*/
|
||||
import { fetchAuthApi } from '@/api/client';
|
||||
|
||||
/**
|
||||
* 카테고리 목록 조회
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getCategories() {
|
||||
return fetchAuthApi('/schedules/categories');
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 생성
|
||||
* @param {object} data - 카테고리 데이터
|
||||
* @param {string} data.name - 카테고리 이름
|
||||
* @param {string} data.color - 색상 코드
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function createCategory(data) {
|
||||
return fetchAuthApi('/admin/schedule-categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 수정
|
||||
* @param {number} id - 카테고리 ID
|
||||
* @param {object} data - 카테고리 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function updateCategory(id, data) {
|
||||
return fetchAuthApi(`/admin/schedule-categories/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 삭제
|
||||
* @param {number} id - 카테고리 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteCategory(id) {
|
||||
return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 순서 변경
|
||||
* @param {Array<{id: number, sort_order: number}>} orders - 순서 데이터
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function reorderCategories(orders) {
|
||||
return fetchAuthApi('/admin/schedule-categories-order', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ orders }),
|
||||
});
|
||||
}
|
||||
14
frontend-temp/src/api/admin/index.js
Normal file
14
frontend-temp/src/api/admin/index.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* 관리자 API 통합 export
|
||||
*/
|
||||
export * as adminScheduleApi from './schedules';
|
||||
export * as adminAlbumApi from './albums';
|
||||
export * as adminMemberApi from './members';
|
||||
export * as adminCategoryApi from './categories';
|
||||
export * as adminBotApi from './bots';
|
||||
export * as adminStatsApi from './stats';
|
||||
export * as adminSuggestionApi from './suggestions';
|
||||
export * as adminAuthApi from './auth';
|
||||
|
||||
// 개별 함수 export
|
||||
export * from './auth';
|
||||
31
frontend-temp/src/api/admin/members.js
Normal file
31
frontend-temp/src/api/admin/members.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* 관리자 멤버 API
|
||||
*/
|
||||
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
||||
|
||||
/**
|
||||
* 멤버 목록 조회
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getMembers() {
|
||||
return fetchAuthApi('/members');
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 상세 조회
|
||||
* @param {number} id - 멤버 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getMember(id) {
|
||||
return fetchAuthApi(`/members/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 수정
|
||||
* @param {number} id - 멤버 ID
|
||||
* @param {FormData} formData - 멤버 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function updateMember(id, formData) {
|
||||
return fetchFormData(`/members/${id}`, formData, 'PUT');
|
||||
}
|
||||
102
frontend-temp/src/api/admin/schedules.js
Normal file
102
frontend-temp/src/api/admin/schedules.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* 관리자 일정 API
|
||||
*/
|
||||
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
||||
|
||||
/**
|
||||
* API 응답을 프론트엔드 형식으로 변환
|
||||
* - datetime → date, time 분리
|
||||
* - category 객체 → category_id, category_name, category_color 플랫화
|
||||
* - members 배열 → member_names 문자열
|
||||
*/
|
||||
function transformSchedule(schedule) {
|
||||
const category = schedule.category || {};
|
||||
|
||||
// datetime에서 date와 time 분리
|
||||
let date = '';
|
||||
let time = null;
|
||||
if (schedule.datetime) {
|
||||
const parts = schedule.datetime.split('T');
|
||||
date = parts[0];
|
||||
time = parts[1] || null;
|
||||
}
|
||||
|
||||
// members 배열을 문자열로 (기존 코드 호환성)
|
||||
const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') : '';
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
date,
|
||||
time,
|
||||
category_id: category.id,
|
||||
category_name: category.name,
|
||||
category_color: category.color,
|
||||
member_names: memberNames,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 목록 조회 (월별)
|
||||
* @param {number} year - 년도
|
||||
* @param {number} month - 월
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getSchedules(year, month) {
|
||||
const data = await fetchAuthApi(`/schedules?year=${year}&month=${month}`);
|
||||
return (data.schedules || []).map(transformSchedule);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 검색 (Meilisearch)
|
||||
* @param {string} query - 검색어
|
||||
* @param {object} options - 페이지네이션 옵션
|
||||
* @param {number} options.offset - 시작 위치
|
||||
* @param {number} options.limit - 조회 개수
|
||||
* @returns {Promise<{schedules: Array, total: number}>}
|
||||
*/
|
||||
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
||||
const data = await fetchAuthApi(
|
||||
`/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
|
||||
);
|
||||
return {
|
||||
...data,
|
||||
schedules: (data.schedules || []).map(transformSchedule),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 상세 조회
|
||||
* @param {number} id - 일정 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getSchedule(id) {
|
||||
return fetchAuthApi(`/schedules/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 생성
|
||||
* @param {FormData} formData - 일정 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function createSchedule(formData) {
|
||||
return fetchFormData('/admin/schedules', formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 수정
|
||||
* @param {number} id - 일정 ID
|
||||
* @param {FormData} formData - 일정 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function updateSchedule(id, formData) {
|
||||
return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 삭제
|
||||
* @param {number} id - 일정 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteSchedule(id) {
|
||||
return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
12
frontend-temp/src/api/admin/stats.js
Normal file
12
frontend-temp/src/api/admin/stats.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* 관리자 통계 API
|
||||
*/
|
||||
import { fetchAuthApi } from '@/api/client';
|
||||
|
||||
/**
|
||||
* 대시보드 통계 조회
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getStats() {
|
||||
return fetchAuthApi('/stats');
|
||||
}
|
||||
24
frontend-temp/src/api/admin/suggestions.js
Normal file
24
frontend-temp/src/api/admin/suggestions.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* 관리자 추천 검색어 API
|
||||
*/
|
||||
import { fetchAuthApi } from '@/api/client';
|
||||
|
||||
/**
|
||||
* 사전 내용 조회
|
||||
* @returns {Promise<{content: string}>}
|
||||
*/
|
||||
export async function getDict() {
|
||||
return fetchAuthApi('/schedules/suggestions/dict');
|
||||
}
|
||||
|
||||
/**
|
||||
* 사전 저장
|
||||
* @param {string} content - 사전 내용
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function saveDict(content) {
|
||||
return fetchAuthApi('/schedules/suggestions/dict', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
155
frontend-temp/src/api/client.js
Normal file
155
frontend-temp/src/api/client.js
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* API 클라이언트
|
||||
* 모든 API 호출에서 사용되는 기본 fetch 래퍼
|
||||
*/
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
/**
|
||||
* API 에러 클래스
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(message, status, data = null) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 처리 헬퍼
|
||||
*/
|
||||
async function handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: '요청 실패' }));
|
||||
throw new ApiError(
|
||||
error.error || error.message || `HTTP ${response.status}`,
|
||||
response.status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// 204 No Content 처리
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 API fetch
|
||||
* @param {string} endpoint - API 엔드포인트 (/api 제외)
|
||||
* @param {RequestInit} options - fetch 옵션
|
||||
*/
|
||||
export async function fetchApi(endpoint, options = {}) {
|
||||
const url = endpoint.startsWith('/api') ? endpoint : `${API_BASE}${endpoint}`;
|
||||
const headers = { ...options.headers };
|
||||
|
||||
// body가 있고 FormData가 아닐 때만 Content-Type 설정
|
||||
if (options.body && !(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증된 API fetch (토큰 자동 추가)
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {RequestInit} options - fetch 옵션
|
||||
*/
|
||||
export async function fetchAuthApi(endpoint, options = {}) {
|
||||
const token = useAuthStore.getState().token;
|
||||
|
||||
if (!token) {
|
||||
throw new ApiError('인증이 필요합니다.', 401);
|
||||
}
|
||||
|
||||
return fetchApi(endpoint, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FormData 전송용 (이미지 업로드 등)
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {FormData} formData - 전송할 FormData
|
||||
* @param {string} method - HTTP 메서드 (기본: POST)
|
||||
* @param {Object} options - 추가 옵션
|
||||
* @param {boolean} options.requireAuth - 인증 필수 여부 (기본: true)
|
||||
*/
|
||||
export async function fetchFormData(endpoint, formData, method = 'POST', { requireAuth = true } = {}) {
|
||||
const token = useAuthStore.getState().token;
|
||||
const url = endpoint.startsWith('/api') ? endpoint : `${API_BASE}${endpoint}`;
|
||||
|
||||
if (requireAuth && !token) {
|
||||
throw new ApiError('인증이 필요합니다.', 401);
|
||||
}
|
||||
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 메서드 헬퍼 생성기
|
||||
*/
|
||||
function createMethodHelpers(baseFetch) {
|
||||
return {
|
||||
get: (endpoint) => baseFetch(endpoint),
|
||||
post: (endpoint, data) =>
|
||||
baseFetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
put: (endpoint, data) =>
|
||||
baseFetch(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
del: (endpoint) => baseFetch(endpoint, { method: 'DELETE' }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 API 헬퍼
|
||||
* @example api.get('/albums'), api.post('/albums', data)
|
||||
*/
|
||||
export const api = createMethodHelpers(fetchApi);
|
||||
|
||||
/**
|
||||
* 인증 API 헬퍼
|
||||
* @example authApi.get('/admin/stats'), authApi.post('/admin/schedules', data)
|
||||
*/
|
||||
export const authApi = createMethodHelpers(fetchAuthApi);
|
||||
|
||||
// 기존 호환성을 위한 개별 export (점진적 마이그레이션 후 삭제 예정)
|
||||
export const get = api.get;
|
||||
export const post = api.post;
|
||||
export const put = api.put;
|
||||
export const del = api.del;
|
||||
export const authGet = authApi.get;
|
||||
export const authPost = authApi.post;
|
||||
export const authPut = authApi.put;
|
||||
export const authDel = authApi.del;
|
||||
16
frontend-temp/src/api/index.js
Normal file
16
frontend-temp/src/api/index.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* API 통합 export
|
||||
*/
|
||||
|
||||
// 공통 유틸리티
|
||||
export * from './client';
|
||||
|
||||
// 공개 API
|
||||
export * from './public';
|
||||
export * as scheduleApi from './public/schedules';
|
||||
export * as albumApi from './public/albums';
|
||||
export * as memberApi from './public/members';
|
||||
|
||||
// 관리자 API
|
||||
export * from './admin';
|
||||
export * as authApi from './admin/auth';
|
||||
101
frontend-temp/src/api/public/albums.js
Normal file
101
frontend-temp/src/api/public/albums.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* 앨범 API
|
||||
*/
|
||||
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
|
||||
|
||||
// ==================== 공개 API ====================
|
||||
|
||||
/**
|
||||
* 앨범 목록 조회
|
||||
*/
|
||||
export async function getAlbums() {
|
||||
return fetchApi('/albums');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 상세 조회 (ID)
|
||||
*/
|
||||
export async function getAlbum(id) {
|
||||
return fetchApi(`/albums/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 상세 조회 (이름)
|
||||
*/
|
||||
export async function getAlbumByName(name) {
|
||||
return fetchApi(`/albums/by-name/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 사진 조회
|
||||
*/
|
||||
export async function getAlbumPhotos(albumId) {
|
||||
return fetchApi(`/albums/${albumId}/photos`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 트랙 조회
|
||||
*/
|
||||
export async function getAlbumTracks(albumId) {
|
||||
return fetchApi(`/albums/${albumId}/tracks`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 트랙 상세 조회 (앨범명, 트랙명으로)
|
||||
*/
|
||||
export async function getTrack(albumName, trackTitle) {
|
||||
return fetchApi(
|
||||
`/albums/by-name/${encodeURIComponent(albumName)}/track/${encodeURIComponent(trackTitle)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 티저 조회
|
||||
*/
|
||||
export async function getAlbumTeasers(albumId) {
|
||||
return fetchApi(`/albums/${albumId}/teasers`);
|
||||
}
|
||||
|
||||
// ==================== 어드민 API ====================
|
||||
|
||||
/**
|
||||
* [Admin] 앨범 생성
|
||||
*/
|
||||
export async function createAlbum(formData) {
|
||||
return fetchFormData('/albums', formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 앨범 수정
|
||||
*/
|
||||
export async function updateAlbum(id, formData) {
|
||||
return fetchFormData(`/albums/${id}`, formData, 'PUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 앨범 삭제
|
||||
*/
|
||||
export async function deleteAlbum(id) {
|
||||
return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 앨범 사진 업로드
|
||||
*/
|
||||
export async function uploadAlbumPhotos(albumId, formData) {
|
||||
return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 앨범 사진 삭제
|
||||
*/
|
||||
export async function deleteAlbumPhoto(albumId, photoId) {
|
||||
return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 앨범 티저 삭제
|
||||
*/
|
||||
export async function deleteAlbumTeaser(albumId, teaserId) {
|
||||
return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' });
|
||||
}
|
||||
6
frontend-temp/src/api/public/index.js
Normal file
6
frontend-temp/src/api/public/index.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* 공개 API 통합 export
|
||||
*/
|
||||
export * from './schedules';
|
||||
export * from './albums';
|
||||
export * from './members';
|
||||
43
frontend-temp/src/api/public/members.js
Normal file
43
frontend-temp/src/api/public/members.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* 멤버 API
|
||||
*/
|
||||
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
|
||||
|
||||
// ==================== 공개 API ====================
|
||||
|
||||
/**
|
||||
* 멤버 목록 조회
|
||||
*/
|
||||
export async function getMembers() {
|
||||
return fetchApi('/members');
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 상세 조회
|
||||
*/
|
||||
export async function getMember(id) {
|
||||
return fetchApi(`/members/${id}`);
|
||||
}
|
||||
|
||||
// ==================== 어드민 API ====================
|
||||
|
||||
/**
|
||||
* [Admin] 멤버 생성
|
||||
*/
|
||||
export async function createMember(formData) {
|
||||
return fetchFormData('/admin/members', formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 멤버 수정
|
||||
*/
|
||||
export async function updateMember(id, formData) {
|
||||
return fetchFormData(`/admin/members/${id}`, formData, 'PUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 멤버 삭제
|
||||
*/
|
||||
export async function deleteMember(id) {
|
||||
return fetchAuthApi(`/admin/members/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
169
frontend-temp/src/api/public/schedules.js
Normal file
169
frontend-temp/src/api/public/schedules.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* 스케줄 API
|
||||
*/
|
||||
import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
|
||||
import { getTodayKST, dayjs } from '@/utils';
|
||||
|
||||
/**
|
||||
* API 응답을 프론트엔드 형식으로 변환
|
||||
* - datetime → date, time 분리
|
||||
* - category 객체 → category_id, category_name, category_color 플랫화
|
||||
* - members 배열 → member_names 문자열
|
||||
*/
|
||||
function transformSchedule(schedule) {
|
||||
const category = schedule.category || {};
|
||||
|
||||
// datetime에서 date와 time 분리
|
||||
let date = '';
|
||||
let time = null;
|
||||
if (schedule.datetime) {
|
||||
const dt = dayjs(schedule.datetime);
|
||||
date = dt.format('YYYY-MM-DD');
|
||||
// datetime에 T가 포함되어 있으면 시간이 있는 것
|
||||
time = schedule.datetime.includes('T') ? dt.format('HH:mm:ss') : null;
|
||||
}
|
||||
|
||||
// members 배열을 문자열로 (기존 코드 호환성)
|
||||
const memberNames = Array.isArray(schedule.members)
|
||||
? schedule.members.join(',')
|
||||
: '';
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
date,
|
||||
time,
|
||||
category_id: category.id,
|
||||
category_name: category.name,
|
||||
category_color: category.color,
|
||||
member_names: memberNames,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 공개 API ====================
|
||||
|
||||
/**
|
||||
* 스케줄 목록 조회 (월별)
|
||||
*/
|
||||
export async function getSchedules(year, month) {
|
||||
const data = await fetchApi(`/schedules?year=${year}&month=${month}`);
|
||||
return (data.schedules || []).map(transformSchedule);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다가오는 스케줄 조회
|
||||
*/
|
||||
export async function getUpcomingSchedules(limit = 3) {
|
||||
const today = getTodayKST();
|
||||
const data = await fetchApi(`/schedules?startDate=${today}&limit=${limit}`);
|
||||
return (data.schedules || []).map(transformSchedule);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 검색 (Meilisearch)
|
||||
*/
|
||||
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
||||
const data = await fetchApi(
|
||||
`/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
|
||||
);
|
||||
return {
|
||||
...data,
|
||||
schedules: (data.schedules || []).map(transformSchedule),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 상세 조회
|
||||
*/
|
||||
export async function getSchedule(id) {
|
||||
return fetchApi(`/schedules/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* X 프로필 정보 조회
|
||||
*/
|
||||
export async function getXProfile(username) {
|
||||
return fetchApi(`/schedules/x-profile/${encodeURIComponent(username)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 목록 조회
|
||||
*/
|
||||
export async function getCategories() {
|
||||
return fetchApi('/schedules/categories');
|
||||
}
|
||||
|
||||
// ==================== 어드민 API ====================
|
||||
|
||||
/**
|
||||
* [Admin] 스케줄 검색
|
||||
*/
|
||||
export async function adminSearchSchedules(query) {
|
||||
return fetchAuthApi(`/admin/schedules/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 스케줄 상세 조회
|
||||
*/
|
||||
export async function adminGetSchedule(id) {
|
||||
return fetchAuthApi(`/admin/schedules/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 스케줄 생성
|
||||
*/
|
||||
export async function createSchedule(formData) {
|
||||
return fetchFormData('/admin/schedules', formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 스케줄 수정
|
||||
*/
|
||||
export async function updateSchedule(id, formData) {
|
||||
return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 스케줄 삭제
|
||||
*/
|
||||
export async function deleteSchedule(id) {
|
||||
return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ==================== 카테고리 어드민 API ====================
|
||||
|
||||
/**
|
||||
* [Admin] 카테고리 생성
|
||||
*/
|
||||
export async function createCategory(data) {
|
||||
return fetchAuthApi('/admin/schedule-categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 카테고리 수정
|
||||
*/
|
||||
export async function updateCategory(id, data) {
|
||||
return fetchAuthApi(`/admin/schedule-categories/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 카테고리 삭제
|
||||
*/
|
||||
export async function deleteCategory(id) {
|
||||
return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* [Admin] 카테고리 순서 변경
|
||||
*/
|
||||
export async function reorderCategories(orders) {
|
||||
return fetchAuthApi('/admin/schedule-categories-order', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ orders }),
|
||||
});
|
||||
}
|
||||
32
frontend-temp/src/components/common/AnimatedNumber.jsx
Normal file
32
frontend-temp/src/components/common/AnimatedNumber.jsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* 슬롯머신 스타일 롤링 숫자 컴포넌트
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const AnimatedNumber = memo(function AnimatedNumber({ value, className = '' }) {
|
||||
const chars = String(value).split('');
|
||||
|
||||
return (
|
||||
<span className={`inline-flex overflow-hidden ${className}`}>
|
||||
{chars.map((char, i) => (
|
||||
<span key={i} className="relative h-[1.2em] overflow-hidden">
|
||||
<motion.span
|
||||
className="flex flex-col"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: `-${parseInt(char) * 10}%` }}
|
||||
transition={{ type: 'tween', ease: 'easeOut', duration: 0.8, delay: i * 0.1 }}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||
<span key={n} className="h-[1.2em] flex items-center justify-center">
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</motion.span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export default AnimatedNumber;
|
||||
45
frontend-temp/src/components/common/ErrorBoundary.jsx
Normal file
45
frontend-temp/src/components/common/ErrorBoundary.jsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Component } from 'react';
|
||||
|
||||
/**
|
||||
* 에러 바운더리 컴포넌트
|
||||
* React 컴포넌트 트리에서 발생하는 에러를 캐치
|
||||
*/
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[200px] p-8">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-2">
|
||||
문제가 발생했습니다
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm mb-4">
|
||||
페이지를 새로고침하거나 잠시 후 다시 시도해주세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
36
frontend-temp/src/components/common/ErrorMessage.jsx
Normal file
36
frontend-temp/src/components/common/ErrorMessage.jsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 에러 메시지 컴포넌트
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {function} onRetry - 재시도 콜백 함수
|
||||
* @param {string} className - 추가 CSS 클래스
|
||||
*/
|
||||
function ErrorMessage({
|
||||
message = '데이터를 불러오는데 실패했습니다.',
|
||||
onRetry,
|
||||
className = '',
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`flex flex-col items-center justify-center py-12 px-4 ${className}`}
|
||||
>
|
||||
<AlertCircle size={48} className="text-red-400 mb-4" aria-hidden="true" />
|
||||
<p className="text-gray-600 text-center mb-4">{message}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
다시 시도
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorMessage;
|
||||
290
frontend-temp/src/components/common/Lightbox.jsx
Normal file
290
frontend-temp/src/components/common/Lightbox.jsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import { useState, useEffect, useCallback, memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import LightboxIndicator from './LightboxIndicator';
|
||||
|
||||
/**
|
||||
* 라이트박스 공통 컴포넌트
|
||||
* 이미지/비디오 갤러리를 전체 화면으로 표시
|
||||
*
|
||||
* @param {string[]} images - 이미지/비디오 URL 배열
|
||||
* @param {Object[]} photos - 메타데이터 포함 사진 배열 (선택적)
|
||||
* @param {string} photos[].title - 컨셉 이름
|
||||
* @param {string} photos[].members - 멤버 이름 (쉼표 구분)
|
||||
* @param {Object[]} teasers - 티저 정보 배열 (비디오 여부 확인용)
|
||||
* @param {string} teasers[].media_type - 'video' 또는 'image'
|
||||
* @param {number} currentIndex - 현재 인덱스
|
||||
* @param {boolean} isOpen - 열림 상태
|
||||
* @param {function} onClose - 닫기 콜백
|
||||
* @param {function} onIndexChange - 인덱스 변경 콜백
|
||||
* @param {boolean} showCounter - 카운터 표시 여부 (기본: true)
|
||||
* @param {boolean} showDownload - 다운로드 버튼 표시 여부 (기본: true)
|
||||
*/
|
||||
function Lightbox({
|
||||
images,
|
||||
photos,
|
||||
teasers,
|
||||
currentIndex,
|
||||
isOpen,
|
||||
onClose,
|
||||
onIndexChange,
|
||||
showCounter = true,
|
||||
showDownload = true,
|
||||
}) {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [slideDirection, setSlideDirection] = useState(0);
|
||||
|
||||
// 이전/다음 네비게이션
|
||||
const goToPrev = useCallback(() => {
|
||||
if (images.length <= 1) return;
|
||||
setImageLoaded(false);
|
||||
setSlideDirection(-1);
|
||||
onIndexChange((currentIndex - 1 + images.length) % images.length);
|
||||
}, [images.length, currentIndex, onIndexChange]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (images.length <= 1) return;
|
||||
setImageLoaded(false);
|
||||
setSlideDirection(1);
|
||||
onIndexChange((currentIndex + 1) % images.length);
|
||||
}, [images.length, currentIndex, onIndexChange]);
|
||||
|
||||
const goToIndex = useCallback(
|
||||
(index) => {
|
||||
if (index === currentIndex) return;
|
||||
setImageLoaded(false);
|
||||
setSlideDirection(index > currentIndex ? 1 : -1);
|
||||
onIndexChange(index);
|
||||
},
|
||||
[currentIndex, onIndexChange]
|
||||
);
|
||||
|
||||
// 이미지 다운로드
|
||||
const downloadImage = useCallback(async () => {
|
||||
const imageUrl = images[currentIndex];
|
||||
if (!imageUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `image_${currentIndex + 1}.jpg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('이미지 다운로드 실패:', error);
|
||||
}
|
||||
}, [images, currentIndex]);
|
||||
|
||||
// 라이트박스 열릴 때 body 스크롤 숨기기
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
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 = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 키보드 이벤트 핸들러
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
goToPrev();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
goToNext();
|
||||
break;
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, goToPrev, goToNext, onClose]);
|
||||
|
||||
// 이미지가 바뀔 때 로딩 상태 리셋
|
||||
useEffect(() => {
|
||||
setImageLoaded(false);
|
||||
}, [currentIndex]);
|
||||
|
||||
// 현재 사진의 메타데이터
|
||||
const currentPhoto = photos?.[currentIndex];
|
||||
const photoTitle = currentPhoto?.title;
|
||||
const hasValidTitle = photoTitle && photoTitle.trim() && photoTitle !== 'Default';
|
||||
const photoMembers = currentPhoto?.members;
|
||||
const hasMembers = photoMembers && String(photoMembers).trim();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && images.length > 0 && (
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="이미지 뷰어"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 bg-black/95 z-50 overflow-scroll"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* 내부 컨테이너 */}
|
||||
<div className="min-w-[1400px] min-h-[1200px] w-full h-full relative flex items-center justify-center">
|
||||
{/* 카운터 */}
|
||||
{showCounter && images.length > 1 && (
|
||||
<div className="absolute top-6 left-6 text-white/70 text-sm z-10">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 상단 버튼들 */}
|
||||
<div className="absolute top-6 right-6 flex gap-3 z-10">
|
||||
{showDownload && (
|
||||
<button
|
||||
aria-label="다운로드"
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadImage();
|
||||
}}
|
||||
>
|
||||
<Download size={28} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="닫기"
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X size={32} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 이전 버튼 */}
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
aria-label="이전 이미지"
|
||||
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToPrev();
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={48} aria-hidden="true" />
|
||||
</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">
|
||||
{teasers?.[currentIndex]?.media_type === 'video' ? (
|
||||
<motion.video
|
||||
key={currentIndex}
|
||||
src={images[currentIndex]}
|
||||
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
|
||||
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCanPlay={() => setImageLoaded(true)}
|
||||
initial={{ x: slideDirection * 100 }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
controls
|
||||
autoPlay
|
||||
/>
|
||||
) : (
|
||||
<motion.img
|
||||
key={currentIndex}
|
||||
src={images[currentIndex]}
|
||||
alt={`이미지 ${currentIndex + 1}`}
|
||||
className={`max-w-[1100px] max-h-[900px] object-contain 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' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 컨셉/멤버 정보 */}
|
||||
{imageLoaded && (hasValidTitle || hasMembers) && (
|
||||
<div className="mt-6 flex flex-col items-center gap-2">
|
||||
{hasValidTitle && (
|
||||
<span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base">
|
||||
{photoTitle}
|
||||
</span>
|
||||
)}
|
||||
{hasMembers && (
|
||||
<div className="flex items-center gap-2">
|
||||
{String(photoMembers)
|
||||
.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>
|
||||
|
||||
{/* 다음 버튼 */}
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
aria-label="다음 이미지"
|
||||
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToNext();
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={48} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 인디케이터 */}
|
||||
{images.length > 1 && (
|
||||
<LightboxIndicator
|
||||
count={images.length}
|
||||
currentIndex={currentIndex}
|
||||
goToIndex={goToIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default Lightbox;
|
||||
57
frontend-temp/src/components/common/LightboxIndicator.jsx
Normal file
57
frontend-temp/src/components/common/LightboxIndicator.jsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
/**
|
||||
* 라이트박스 인디케이터 컴포넌트
|
||||
* 이미지 갤러리에서 현재 위치를 표시하는 슬라이딩 점 인디케이터
|
||||
* CSS transition 사용으로 GPU 가속
|
||||
*/
|
||||
const LightboxIndicator = memo(function LightboxIndicator({
|
||||
count,
|
||||
currentIndex,
|
||||
goToIndex,
|
||||
width = 200,
|
||||
}) {
|
||||
const halfWidth = width / 2;
|
||||
const translateX = -(currentIndex * 18) + halfWidth - 6;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden"
|
||||
style={{ width: `${width}px` }}
|
||||
>
|
||||
{/* 양옆 페이드 그라데이션 */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none z-10"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
|
||||
<div
|
||||
className="flex items-center gap-2 justify-center"
|
||||
style={{
|
||||
width: `${count * 18}px`,
|
||||
transform: `translateX(${translateX}px)`,
|
||||
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`이미지 ${i + 1}/${count}`}
|
||||
aria-current={i === currentIndex ? 'true' : undefined}
|
||||
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
|
||||
i === currentIndex
|
||||
? 'w-3 h-3 bg-white'
|
||||
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60'
|
||||
}`}
|
||||
onClick={() => goToIndex(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default LightboxIndicator;
|
||||
22
frontend-temp/src/components/common/Loading.jsx
Normal file
22
frontend-temp/src/components/common/Loading.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* 로딩 컴포넌트
|
||||
* @param {string} size - 크기 ('sm' | 'md' | 'lg')
|
||||
* @param {string} className - 추가 CSS 클래스
|
||||
*/
|
||||
function Loading({ size = 'md', className = '' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-6 w-6 border-2',
|
||||
md: 'h-8 w-8 border-3',
|
||||
lg: 'h-12 w-12 border-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`}>
|
||||
<div
|
||||
className={`animate-spin rounded-full border-primary border-t-transparent ${sizeClasses[size] || sizeClasses.md}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Loading;
|
||||
268
frontend-temp/src/components/common/MobileLightbox.jsx
Normal file
268
frontend-temp/src/components/common/MobileLightbox.jsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Download, Info, Users, Tag } from 'lucide-react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Virtual } from 'swiper/modules';
|
||||
import 'swiper/css';
|
||||
import LightboxIndicator from './LightboxIndicator';
|
||||
|
||||
/**
|
||||
* 모바일 라이트박스 공통 컴포넌트
|
||||
* Swiper 기반 터치 스와이프 지원
|
||||
*
|
||||
* @param {string[]} images - 이미지/비디오 URL 배열
|
||||
* @param {Object[]} photos - 메타데이터 포함 사진 배열 (선택적)
|
||||
* @param {string} photos[].concept - 컨셉 이름
|
||||
* @param {string} photos[].members - 멤버 이름 (쉼표 구분)
|
||||
* @param {Object[]} teasers - 티저 정보 배열 (비디오 여부 확인용)
|
||||
* @param {string} teasers[].media_type - 'video' 또는 'image'
|
||||
* @param {number} currentIndex - 현재 인덱스
|
||||
* @param {boolean} isOpen - 열림 상태
|
||||
* @param {function} onClose - 닫기 콜백
|
||||
* @param {function} onIndexChange - 인덱스 변경 콜백
|
||||
* @param {boolean} showCounter - 카운터 표시 여부 (기본: true)
|
||||
* @param {boolean} showDownload - 다운로드 버튼 표시 여부 (기본: true)
|
||||
* @param {string} downloadPrefix - 다운로드 파일명 접두사 (기본: 'fromis9_photo')
|
||||
*/
|
||||
function MobileLightbox({
|
||||
images,
|
||||
photos,
|
||||
teasers,
|
||||
currentIndex,
|
||||
isOpen,
|
||||
onClose,
|
||||
onIndexChange,
|
||||
showCounter = true,
|
||||
showDownload = true,
|
||||
downloadPrefix = 'fromis9_photo',
|
||||
}) {
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const swiperRef = useRef(null);
|
||||
|
||||
// 현재 사진 정보
|
||||
const currentPhoto = photos?.[currentIndex];
|
||||
const concept = currentPhoto?.concept || currentPhoto?.title;
|
||||
const hasValidConcept = concept && concept.trim() && concept !== 'Default';
|
||||
const members = currentPhoto?.members;
|
||||
const hasMembers = members && String(members).trim();
|
||||
const hasPhotoInfo = hasValidConcept || hasMembers;
|
||||
|
||||
// 정보 시트 열기
|
||||
const openInfo = useCallback(() => {
|
||||
setShowInfo(true);
|
||||
window.history.pushState({ infoSheet: true }, '');
|
||||
}, []);
|
||||
|
||||
// 이미지 다운로드
|
||||
const downloadImage = useCallback(async () => {
|
||||
const imageUrl = images?.[currentIndex];
|
||||
if (!imageUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${downloadPrefix}_${String(currentIndex + 1).padStart(2, '0')}.webp`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('다운로드 오류:', error);
|
||||
}
|
||||
}, [images, currentIndex, downloadPrefix]);
|
||||
|
||||
// 바디 스크롤 방지
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 뒤로가기 처리 (정보 시트)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handlePopState = () => {
|
||||
if (showInfo) {
|
||||
setShowInfo(false);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [isOpen, showInfo, onClose]);
|
||||
|
||||
// 라이트박스 닫힐 때 정보 시트도 닫기
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setShowInfo(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 이미지가 없으면 렌더링하지 않음
|
||||
if (!images?.length) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 bg-black z-[60] flex flex-col"
|
||||
>
|
||||
{/* 상단 헤더 */}
|
||||
<div className="absolute top-0 left-0 right-0 flex items-center px-4 py-3 z-20">
|
||||
<div className="flex-1 flex justify-start">
|
||||
<button onClick={() => window.history.back()} className="text-white/80 p-1">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
{showCounter && images.length > 1 && (
|
||||
<span className="text-white/70 text-sm tabular-nums">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 flex justify-end items-center gap-2">
|
||||
{hasPhotoInfo && (
|
||||
<button onClick={openInfo} className="text-white/80 p-1">
|
||||
<Info size={22} />
|
||||
</button>
|
||||
)}
|
||||
{showDownload && (
|
||||
<button onClick={downloadImage} className="text-white/80 p-1">
|
||||
<Download size={22} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Swiper */}
|
||||
<Swiper
|
||||
modules={[Virtual]}
|
||||
virtual
|
||||
initialSlide={currentIndex}
|
||||
onSwiper={(swiper) => {
|
||||
swiperRef.current = swiper;
|
||||
}}
|
||||
onSlideChange={(swiper) => onIndexChange(swiper.activeIndex)}
|
||||
className="w-full h-full"
|
||||
spaceBetween={0}
|
||||
slidesPerView={1}
|
||||
resistance={true}
|
||||
resistanceRatio={0.5}
|
||||
>
|
||||
{images.map((url, index) => (
|
||||
<SwiperSlide key={index} virtualIndex={index}>
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
{teasers?.[index]?.media_type === 'video' ? (
|
||||
<video
|
||||
src={url}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
controls
|
||||
autoPlay={index === currentIndex}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="max-w-full max-h-full object-contain"
|
||||
loading={Math.abs(index - currentIndex) <= 2 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
{/* 인디케이터 */}
|
||||
{images.length > 1 && (
|
||||
<LightboxIndicator
|
||||
count={images.length}
|
||||
currentIndex={currentIndex}
|
||||
goToIndex={(i) => swiperRef.current?.slideTo(i)}
|
||||
width={120}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 사진 정보 바텀시트 */}
|
||||
<AnimatePresence>
|
||||
{showInfo && hasPhotoInfo && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/60 z-30"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
drag="y"
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.5 }}
|
||||
onDragEnd={(_, info) => {
|
||||
if (info.offset.y > 100 || info.velocity.y > 300) {
|
||||
window.history.back();
|
||||
}
|
||||
}}
|
||||
className="absolute bottom-0 left-0 right-0 bg-zinc-900 rounded-t-3xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
|
||||
<div className="w-10 h-1 bg-zinc-600 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* 정보 내용 */}
|
||||
<div className="px-5 pb-8 space-y-4">
|
||||
<h3 className="text-white font-semibold text-lg">사진 정보</h3>
|
||||
|
||||
{hasMembers && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Users size={16} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-400 text-xs mb-1">멤버</p>
|
||||
<p className="text-white">{members}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasValidConcept && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-white/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Tag size={16} className="text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-400 text-xs mb-1">컨셉</p>
|
||||
<p className="text-white">{concept}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileLightbox;
|
||||
24
frontend-temp/src/components/common/ScrollToTop.jsx
Normal file
24
frontend-temp/src/components/common/ScrollToTop.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* 페이지 이동 시 스크롤을 맨 위로 이동시키는 컴포넌트
|
||||
*/
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// window 스크롤 초기화
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// 모바일 레이아웃 스크롤 컨테이너 초기화
|
||||
const mobileContent = document.querySelector('.mobile-content');
|
||||
if (mobileContent) {
|
||||
mobileContent.scrollTop = 0;
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default ScrollToTop;
|
||||
34
frontend-temp/src/components/common/Toast.jsx
Normal file
34
frontend-temp/src/components/common/Toast.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Toast 컴포넌트
|
||||
* - 하단 중앙에 표시
|
||||
* - type: 'success' | 'error' | 'warning'
|
||||
*/
|
||||
function Toast({ toast, onClose }) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<motion.div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 50 }}
|
||||
onClick={onClose}
|
||||
className={`fixed bottom-8 inset-x-0 mx-auto w-fit z-[9999] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg cursor-pointer ${
|
||||
toast.type === 'error'
|
||||
? 'bg-red-500/90'
|
||||
: toast.type === 'warning'
|
||||
? 'bg-amber-500/90'
|
||||
: 'bg-emerald-500/90'
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default Toast;
|
||||
72
frontend-temp/src/components/common/Tooltip.jsx
Normal file
72
frontend-temp/src/components/common/Tooltip.jsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* 커스텀 툴팁 컴포넌트
|
||||
* 마우스 커서를 따라다니는 방식
|
||||
* @param {React.ReactNode} children - 툴팁을 표시할 요소
|
||||
* @param {string|React.ReactNode} text - 툴팁에 표시할 내용 (content prop과 호환)
|
||||
* @param {string|React.ReactNode} content - 툴팁에 표시할 내용 (text prop과 호환)
|
||||
*/
|
||||
function Tooltip({ children, text, content, className = '' }) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ bottom: 0, left: 0 });
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
// text 또는 content prop 사용 (문자열 또는 React 노드)
|
||||
const tooltipContent = text || content;
|
||||
|
||||
const handleMouseEnter = (e) => {
|
||||
// 마우스 커서 위치를 기준으로 툴팁 위치 설정 (커서 위로)
|
||||
setPosition({
|
||||
bottom: window.innerHeight - e.clientY + 10,
|
||||
left: e.clientX,
|
||||
});
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
// 마우스 이동 시 툴팁 위치 업데이트
|
||||
setPosition({
|
||||
bottom: window.innerHeight - e.clientY + 10,
|
||||
left: e.clientX,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className={`inline-flex items-center ${className}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{isVisible &&
|
||||
tooltipContent &&
|
||||
ReactDOM.createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 5, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{
|
||||
bottom: position.bottom,
|
||||
left: position.left,
|
||||
}}
|
||||
className="fixed z-[9999] -translate-x-1/2 px-3 py-2 bg-gray-800 text-white text-xs font-medium rounded-lg shadow-xl pointer-events-none"
|
||||
>
|
||||
{tooltipContent}
|
||||
</motion.div>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tooltip;
|
||||
9
frontend-temp/src/components/common/index.js
Normal file
9
frontend-temp/src/components/common/index.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { default as Loading } from './Loading';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { default as Toast } from './Toast';
|
||||
export { default as Tooltip } from './Tooltip';
|
||||
export { default as ScrollToTop } from './ScrollToTop';
|
||||
export { default as Lightbox } from './Lightbox';
|
||||
export { default as MobileLightbox } from './MobileLightbox';
|
||||
export { default as LightboxIndicator } from './LightboxIndicator';
|
||||
export { default as AnimatedNumber } from './AnimatedNumber';
|
||||
8
frontend-temp/src/components/index.js
Normal file
8
frontend-temp/src/components/index.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// 공통 컴포넌트 (디바이스 무관)
|
||||
export * from './common';
|
||||
|
||||
// PC 컴포넌트
|
||||
export * as PC from './pc/public';
|
||||
|
||||
// Mobile 컴포넌트
|
||||
export * as Mobile from './mobile';
|
||||
5
frontend-temp/src/components/mobile/index.js
Normal file
5
frontend-temp/src/components/mobile/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// 레이아웃
|
||||
export * from './layout';
|
||||
|
||||
// 일정 컴포넌트
|
||||
export * from './schedule';
|
||||
45
frontend-temp/src/components/mobile/layout/BottomNav.jsx
Normal file
45
frontend-temp/src/components/mobile/layout/BottomNav.jsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { Home, Users, Disc3, Calendar } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 모바일 하단 네비게이션
|
||||
*/
|
||||
function MobileBottomNav() {
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '홈', icon: Home },
|
||||
{ path: '/members', label: '멤버', icon: Users },
|
||||
{ path: '/album', label: '앨범', icon: Disc3 },
|
||||
{ path: '/schedule', label: '일정', icon: Calendar },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="flex-shrink-0 bg-white border-t border-gray-200 z-50 safe-area-bottom">
|
||||
<div className="flex items-center justify-around h-16">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
location.pathname === item.path ||
|
||||
(item.path !== '/' && location.pathname.startsWith(item.path));
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => window.scrollTo(0, 0)}
|
||||
className={`flex flex-col items-center justify-center gap-1 w-full h-full transition-colors ${
|
||||
isActive ? 'text-primary' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<Icon size={22} strokeWidth={isActive ? 2.5 : 2} />
|
||||
<span className="text-xs font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileBottomNav;
|
||||
26
frontend-temp/src/components/mobile/layout/Header.jsx
Normal file
26
frontend-temp/src/components/mobile/layout/Header.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* 모바일 헤더 컴포넌트
|
||||
* @param {string} title - 페이지 제목 (없으면 fromis_9)
|
||||
* @param {boolean} noShadow - 그림자 숨김 여부
|
||||
*/
|
||||
function MobileHeader({ title, noShadow = false }) {
|
||||
return (
|
||||
<header
|
||||
className={`bg-white sticky top-0 z-50 ${noShadow ? '' : 'shadow-sm'}`}
|
||||
>
|
||||
<div className="flex items-center justify-center h-14 px-4">
|
||||
{title ? (
|
||||
<span className="text-xl font-bold text-primary">{title}</span>
|
||||
) : (
|
||||
<NavLink to="/" className="text-xl font-bold text-primary">
|
||||
fromis_9
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileHeader;
|
||||
48
frontend-temp/src/components/mobile/layout/Layout.jsx
Normal file
48
frontend-temp/src/components/mobile/layout/Layout.jsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useEffect } from 'react';
|
||||
import MobileHeader from './Header';
|
||||
import MobileBottomNav from './BottomNav';
|
||||
import '@/mobile.css';
|
||||
|
||||
/**
|
||||
* 모바일 레이아웃 컴포넌트
|
||||
* @param {React.ReactNode} children - 페이지 컨텐츠
|
||||
* @param {string} pageTitle - 헤더에 표시할 제목 (없으면 fromis_9)
|
||||
* @param {boolean} hideHeader - true면 헤더 숨김 (일정 페이지처럼 자체 헤더가 있는 경우)
|
||||
* @param {boolean} useCustomLayout - true면 자체 레이아웃 사용
|
||||
* @param {boolean} noShadow - 헤더 그림자 숨김
|
||||
*/
|
||||
function MobileLayout({
|
||||
children,
|
||||
pageTitle,
|
||||
hideHeader = false,
|
||||
useCustomLayout = false,
|
||||
noShadow = false,
|
||||
}) {
|
||||
// 모바일 레이아웃 활성화 (body 스크롤 방지)
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('mobile-layout');
|
||||
return () => {
|
||||
document.documentElement.classList.remove('mobile-layout');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 자체 레이아웃 사용 시 (Schedule 페이지 등)
|
||||
if (useCustomLayout) {
|
||||
return (
|
||||
<div className="mobile-layout-container bg-white">
|
||||
{children}
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-layout-container bg-white">
|
||||
{!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} />}
|
||||
<main className="mobile-content">{children}</main>
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileLayout;
|
||||
3
frontend-temp/src/components/mobile/layout/index.js
Normal file
3
frontend-temp/src/components/mobile/layout/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as Layout } from './Layout';
|
||||
export { default as Header } from './Header';
|
||||
export { default as BottomNav } from './BottomNav';
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { dayjs, decodeHtmlEntities } from '@/utils';
|
||||
|
||||
/**
|
||||
* Mobile용 생일 카드 컴포넌트
|
||||
* @param {Object} schedule - 일정 데이터
|
||||
* @param {boolean} showYear - 년도 표시 여부
|
||||
* @param {number} delay - 애니메이션 딜레이 (초)
|
||||
* @param {function} onClick - 클릭 핸들러
|
||||
*/
|
||||
const BirthdayCard = memo(function BirthdayCard({ schedule, showYear = false, delay = 0, onClick }) {
|
||||
const scheduleDate = dayjs(schedule.date);
|
||||
const formatted = {
|
||||
year: scheduleDate.year(),
|
||||
month: scheduleDate.month() + 1,
|
||||
day: scheduleDate.date(),
|
||||
};
|
||||
|
||||
const CardContent = (
|
||||
<div className="relative overflow-hidden bg-gradient-to-r from-pink-400 via-purple-400 to-indigo-400 rounded-xl shadow-md hover:shadow-lg transition-shadow cursor-pointer">
|
||||
{/* 배경 장식 */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-3 -right-3 w-16 h-16 bg-white/10 rounded-full" />
|
||||
<div className="absolute -bottom-4 -left-4 w-20 h-20 bg-white/10 rounded-full" />
|
||||
<div className="absolute bottom-3 left-8 text-sm">🎉</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center p-4 gap-3">
|
||||
{/* 멤버 사진 */}
|
||||
{schedule.member_image && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-full border-2 border-white/50 shadow-md overflow-hidden bg-white">
|
||||
<img
|
||||
src={schedule.member_image}
|
||||
alt={schedule.member_names}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 text-white flex items-center gap-2 min-w-0">
|
||||
<span className="text-2xl flex-shrink-0">🎂</span>
|
||||
<h3 className="font-bold text-base tracking-wide truncate">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 날짜 뱃지 (showYear가 true일 때만 표시) */}
|
||||
{showYear && (
|
||||
<div className="flex-shrink-0 bg-white/20 backdrop-blur-sm rounded-lg px-3 py-1.5 text-center">
|
||||
<div className="text-white/70 text-[10px] font-medium">{formatted.year}</div>
|
||||
<div className="text-white/70 text-[10px] font-medium">{formatted.month}월</div>
|
||||
<div className="text-white text-xl font-bold">{formatted.day}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// delay가 있으면 motion 사용
|
||||
if (delay > 0) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay, type: 'spring', stiffness: 300, damping: 30 }}
|
||||
onClick={onClick}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{CardContent}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className="cursor-pointer">
|
||||
{CardContent}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default BirthdayCard;
|
||||
398
frontend-temp/src/components/mobile/schedule/Calendar.jsx
Normal file
398
frontend-temp/src/components/mobile/schedule/Calendar.jsx
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { getCategoryInfo } from '@/utils';
|
||||
import { MIN_YEAR, WEEKDAYS } from '@/constants';
|
||||
|
||||
/**
|
||||
* Mobile용 달력 컴포넌트 (팝업형)
|
||||
* @param {Date} selectedDate - 선택된 날짜
|
||||
* @param {Array} schedules - 일정 목록 (점 표시용)
|
||||
* @param {function} onSelectDate - 날짜 선택 핸들러
|
||||
* @param {boolean} hideHeader - 헤더 숨김 여부
|
||||
* @param {Date} externalViewDate - 외부에서 제어하는 viewDate
|
||||
* @param {function} onViewDateChange - viewDate 변경 콜백
|
||||
* @param {boolean} externalShowYearMonth - 외부에서 제어하는 년월 선택 모드
|
||||
* @param {function} onShowYearMonthChange - 년월 선택 모드 변경 콜백
|
||||
*/
|
||||
function Calendar({
|
||||
selectedDate,
|
||||
schedules = [],
|
||||
onSelectDate,
|
||||
hideHeader = false,
|
||||
externalViewDate,
|
||||
onViewDateChange,
|
||||
externalShowYearMonth,
|
||||
onShowYearMonthChange,
|
||||
}) {
|
||||
const [internalViewDate, setInternalViewDate] = useState(new Date(selectedDate));
|
||||
|
||||
// 외부 viewDate가 있으면 사용, 없으면 내부 상태 사용
|
||||
const viewDate = externalViewDate || internalViewDate;
|
||||
const setViewDate = (date) => {
|
||||
if (onViewDateChange) {
|
||||
onViewDateChange(date);
|
||||
} else {
|
||||
setInternalViewDate(date);
|
||||
}
|
||||
};
|
||||
|
||||
// 터치 스와이프 핸들링
|
||||
const touchStartX = useRef(0);
|
||||
const touchEndX = useRef(0);
|
||||
|
||||
// 날짜별 일정 목록 가져오기 (점 표시용, 최대 3개)
|
||||
const getDaySchedules = (date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const dateStr = `${y}-${m}-${d}`;
|
||||
return schedules.filter((s) => s.date?.split('T')[0] === dateStr).slice(0, 3);
|
||||
};
|
||||
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
|
||||
// 2017년 1월 이전으로 이동 불가
|
||||
const canGoPrevMonth = !(year === MIN_YEAR && month === 0);
|
||||
|
||||
// 달력 데이터 생성 함수
|
||||
const getCalendarDays = useCallback((y, m) => {
|
||||
const firstDay = new Date(y, m, 1);
|
||||
const lastDay = new Date(y, m + 1, 0);
|
||||
const startDay = firstDay.getDay();
|
||||
const daysInMonth = lastDay.getDate();
|
||||
|
||||
const days = [];
|
||||
|
||||
// 이전 달 날짜
|
||||
const prevMonth = new Date(y, m, 0);
|
||||
for (let i = startDay - 1; i >= 0; i--) {
|
||||
days.push({
|
||||
day: prevMonth.getDate() - i,
|
||||
isCurrentMonth: false,
|
||||
date: new Date(y, m - 1, prevMonth.getDate() - i),
|
||||
});
|
||||
}
|
||||
|
||||
// 현재 달 날짜
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({
|
||||
day: i,
|
||||
isCurrentMonth: true,
|
||||
date: new Date(y, m, i),
|
||||
});
|
||||
}
|
||||
|
||||
// 다음 달 날짜 (현재 줄만 채우기)
|
||||
const remaining = (7 - (days.length % 7)) % 7;
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
days.push({
|
||||
day: i,
|
||||
isCurrentMonth: false,
|
||||
date: new Date(y, m + 1, i),
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}, []);
|
||||
|
||||
const changeMonth = useCallback(
|
||||
(delta) => {
|
||||
if (delta < 0 && !canGoPrevMonth) return;
|
||||
const newDate = new Date(viewDate);
|
||||
newDate.setMonth(newDate.getMonth() + delta);
|
||||
setViewDate(newDate);
|
||||
},
|
||||
[viewDate, canGoPrevMonth]
|
||||
);
|
||||
|
||||
const isToday = (date) => {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
// 선택된 날짜인지 확인
|
||||
const isSelected = (date) => {
|
||||
return (
|
||||
date.getDate() === selectedDate.getDate() &&
|
||||
date.getMonth() === selectedDate.getMonth() &&
|
||||
date.getFullYear() === selectedDate.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
// 년월 선택 모드 - 외부에서 제어 가능
|
||||
const [internalShowYearMonth, setInternalShowYearMonth] = useState(false);
|
||||
const showYearMonth =
|
||||
externalShowYearMonth !== undefined ? externalShowYearMonth : internalShowYearMonth;
|
||||
const setShowYearMonth = (value) => {
|
||||
if (onShowYearMonthChange) {
|
||||
onShowYearMonthChange(value);
|
||||
} else {
|
||||
setInternalShowYearMonth(value);
|
||||
}
|
||||
};
|
||||
|
||||
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
|
||||
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
|
||||
const canGoPrevYearRange = yearRangeStart > MIN_YEAR;
|
||||
|
||||
// 배경 스크롤 막기
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 현재 달 캘린더 데이터
|
||||
const currentMonthDays = useMemo(() => {
|
||||
return getCalendarDays(year, month);
|
||||
}, [year, month, getCalendarDays]);
|
||||
|
||||
// 터치 핸들러
|
||||
const handleTouchStart = (e) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
touchEndX.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
const diff = touchStartX.current - touchEndX.current;
|
||||
const threshold = 50;
|
||||
|
||||
if (Math.abs(diff) > threshold) {
|
||||
if (diff > 0) {
|
||||
changeMonth(1);
|
||||
} else {
|
||||
changeMonth(-1);
|
||||
}
|
||||
}
|
||||
touchStartX.current = 0;
|
||||
touchEndX.current = 0;
|
||||
};
|
||||
|
||||
// 월 렌더링 컴포넌트
|
||||
const renderMonth = (days) => (
|
||||
<div onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd}>
|
||||
{/* 요일 헤더 */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{WEEKDAYS.map((day, i) => (
|
||||
<div
|
||||
key={day}
|
||||
className={`text-center text-xs font-medium py-1 ${
|
||||
i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날짜 그리드 */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((item, index) => {
|
||||
const dayOfWeek = index % 7;
|
||||
const isSunday = dayOfWeek === 0;
|
||||
const isSaturday = dayOfWeek === 6;
|
||||
const daySchedules = item.isCurrentMonth ? getDaySchedules(item.date) : [];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
aria-label={`${item.date.getMonth() + 1}월 ${item.day}일${isToday(item.date) ? ' (오늘)' : ''}${daySchedules.length > 0 ? `, 일정 ${daySchedules.length}개` : ''}`}
|
||||
aria-pressed={isSelected(item.date)}
|
||||
onClick={() => onSelectDate(item.date)}
|
||||
className="flex flex-col items-center py-2"
|
||||
>
|
||||
<span
|
||||
className={`w-9 h-9 flex items-center justify-center text-sm font-medium rounded-full transition-all ${
|
||||
!item.isCurrentMonth
|
||||
? 'text-gray-300'
|
||||
: isSelected(item.date)
|
||||
? 'bg-primary text-white font-bold shadow-lg'
|
||||
: isToday(item.date)
|
||||
? 'text-primary font-bold'
|
||||
: isSunday
|
||||
? 'text-red-500 hover:bg-red-50'
|
||||
: isSaturday
|
||||
? 'text-blue-500 hover:bg-blue-50'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{item.day}
|
||||
</span>
|
||||
{/* 일정 점 - 선택된 날짜에는 표시하지 않음, 최대 3개 */}
|
||||
{!isSelected(item.date) && daySchedules.length > 0 && (
|
||||
<div className="flex gap-0.5 mt-0.5 h-1.5">
|
||||
{daySchedules.map((schedule, i) => {
|
||||
const categoryInfo = getCategoryInfo(schedule);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1 h-1 rounded-full"
|
||||
style={{ backgroundColor: categoryInfo.color }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{showYearMonth ? (
|
||||
// 년월 선택 UI
|
||||
<motion.div
|
||||
key="yearMonth"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* 년도 범위 헤더 */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button
|
||||
aria-label="이전 연도 범위"
|
||||
onClick={() =>
|
||||
canGoPrevYearRange && setYearRangeStart(Math.max(MIN_YEAR, yearRangeStart - 12))
|
||||
}
|
||||
disabled={!canGoPrevYearRange}
|
||||
className={`p-1 ${canGoPrevYearRange ? '' : 'opacity-30'}`}
|
||||
>
|
||||
<ChevronLeft size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<span className="font-semibold text-sm">
|
||||
{yearRangeStart} - {yearRangeStart + 11}
|
||||
</span>
|
||||
<button aria-label="다음 연도 범위" onClick={() => setYearRangeStart(yearRangeStart + 12)} className="p-1">
|
||||
<ChevronRight size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 년도 선택 */}
|
||||
<div className="text-center text-xs text-gray-400 mb-2">년도</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
{yearRange.map((y) => {
|
||||
const isCurrentYear = y === new Date().getFullYear();
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
aria-label={`${y}년 선택`}
|
||||
aria-pressed={y === year}
|
||||
onClick={() => {
|
||||
const newDate = new Date(viewDate);
|
||||
newDate.setFullYear(y);
|
||||
setViewDate(newDate);
|
||||
}}
|
||||
className={`py-2 text-sm rounded-lg transition-colors ${
|
||||
y === year
|
||||
? 'bg-primary text-white'
|
||||
: isCurrentYear
|
||||
? 'text-primary font-semibold hover:bg-gray-100'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 월 선택 */}
|
||||
<div className="text-center text-xs text-gray-400 mb-2">월</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => {
|
||||
const today = new Date();
|
||||
const isCurrentMonth = year === today.getFullYear() && m === today.getMonth() + 1;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
aria-label={`${m}월 선택`}
|
||||
aria-pressed={m === month + 1}
|
||||
onClick={() => {
|
||||
const newDate = new Date(year, m - 1, 1);
|
||||
setViewDate(newDate);
|
||||
setShowYearMonth(false);
|
||||
}}
|
||||
className={`py-2 text-sm rounded-lg transition-colors ${
|
||||
m === month + 1
|
||||
? 'bg-primary text-white'
|
||||
: isCurrentMonth
|
||||
? 'text-primary font-semibold hover:bg-gray-100'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{m}월
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={`calendar-${year}-${month}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* 달력 헤더 - hideHeader일 때 숨김 */}
|
||||
{!hideHeader && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
aria-label="이전 달"
|
||||
onClick={() => changeMonth(-1)}
|
||||
disabled={!canGoPrevMonth}
|
||||
className={`p-1 ${!canGoPrevMonth ? 'opacity-30' : ''}`}
|
||||
>
|
||||
<ChevronLeft size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="년/월 선택"
|
||||
aria-expanded={showYearMonth}
|
||||
onClick={() => setShowYearMonth(true)}
|
||||
className="flex items-center gap-1 font-semibold text-sm hover:text-primary transition-colors"
|
||||
>
|
||||
{year}년 {month + 1}월
|
||||
<ChevronDown size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button aria-label="다음 달" onClick={() => changeMonth(1)} className="p-1">
|
||||
<ChevronRight size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 달력 (터치 스와이프 지원) */}
|
||||
{renderMonth(currentMonthDays)}
|
||||
|
||||
{/* 오늘 버튼 */}
|
||||
<div className="mt-3 flex justify-center">
|
||||
<button
|
||||
aria-label="오늘 날짜로 이동"
|
||||
onClick={() => onSelectDate(new Date())}
|
||||
className="text-xs text-primary font-medium px-4 py-1.5 bg-primary/10 rounded-full"
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Calendar;
|
||||
100
frontend-temp/src/components/mobile/schedule/ScheduleCard.jsx
Normal file
100
frontend-temp/src/components/mobile/schedule/ScheduleCard.jsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { memo } from 'react';
|
||||
import { Clock, Tag, Link2 } from 'lucide-react';
|
||||
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||
|
||||
/**
|
||||
* Mobile용 일정 카드 컴포넌트 (홈용)
|
||||
* 홈 페이지의 다가오는 일정 섹션에서 사용
|
||||
* 간결한 레이아웃
|
||||
*/
|
||||
const ScheduleCard = memo(function ScheduleCard({ schedule, onClick, className = '' }) {
|
||||
const scheduleDate = new Date(schedule.date);
|
||||
const today = new Date();
|
||||
const currentYear = today.getFullYear();
|
||||
const currentMonth = today.getMonth();
|
||||
|
||||
const scheduleYear = scheduleDate.getFullYear();
|
||||
const scheduleMonth = scheduleDate.getMonth();
|
||||
const isCurrentYear = scheduleYear === currentYear;
|
||||
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
|
||||
|
||||
const categoryInfo = getCategoryInfo(schedule);
|
||||
const timeStr = getScheduleTime(schedule);
|
||||
const displayMembers = getDisplayMembers(schedule);
|
||||
const sourceName = schedule.source?.name;
|
||||
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`flex gap-4 bg-white p-4 rounded-xl shadow-sm border border-gray-100 overflow-hidden ${className}`}
|
||||
>
|
||||
{/* 날짜 영역 */}
|
||||
<div className="flex flex-col items-center justify-center min-w-[50px]">
|
||||
{!isCurrentYear && (
|
||||
<span className="text-[10px] text-gray-400 font-medium">
|
||||
{scheduleYear}.{scheduleMonth + 1}
|
||||
</span>
|
||||
)}
|
||||
{isCurrentYear && !isCurrentMonth && (
|
||||
<span className="text-[10px] text-gray-400 font-medium">
|
||||
{scheduleMonth + 1}월
|
||||
</span>
|
||||
)}
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{scheduleDate.getDate()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 font-medium">
|
||||
{dayNames[scheduleDate.getDay()]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 세로 구분선 */}
|
||||
<div className="w-px bg-gray-100" />
|
||||
|
||||
{/* 내용 영역 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</p>
|
||||
{/* 시간 + 카테고리 + 소스 */}
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-gray-400">
|
||||
{timeStr && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{timeStr}
|
||||
</span>
|
||||
)}
|
||||
{categoryInfo.name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={12} />
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{sourceName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 size={12} />
|
||||
{sourceName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 멤버 */}
|
||||
{displayMembers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{displayMembers.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 bg-primary/10 text-primary text-[10px] rounded-full font-medium"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ScheduleCard;
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Clock, Link2 } from 'lucide-react';
|
||||
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||
|
||||
/**
|
||||
* Mobile용 일정 리스트 카드 컴포넌트 (타임라인용)
|
||||
* 스케줄 페이지에서 날짜별 일정 목록에 사용
|
||||
* 날짜가 이미 헤더에 표시되므로 날짜 없이 표시
|
||||
*/
|
||||
const ScheduleListCard = memo(function ScheduleListCard({
|
||||
schedule,
|
||||
onClick,
|
||||
delay = 0,
|
||||
className = '',
|
||||
}) {
|
||||
const categoryInfo = getCategoryInfo(schedule);
|
||||
const timeStr = getScheduleTime(schedule);
|
||||
const displayMembers = getDisplayMembers(schedule);
|
||||
const sourceName = schedule.source?.name;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay, type: 'spring', stiffness: 300, damping: 30 }}
|
||||
onClick={onClick}
|
||||
className={`cursor-pointer ${className}`}
|
||||
>
|
||||
{/* 카드 본체 */}
|
||||
<div className="relative bg-white rounded-md shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-gray-100/50 overflow-hidden active:bg-gray-50 transition-colors">
|
||||
<div className="p-4">
|
||||
{/* 시간 및 카테고리 뱃지 */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
{timeStr && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-white text-xs font-medium"
|
||||
style={{ backgroundColor: categoryInfo.color }}
|
||||
>
|
||||
<Clock size={10} />
|
||||
{timeStr}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${categoryInfo.color}15`,
|
||||
color: categoryInfo.color,
|
||||
}}
|
||||
>
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className="font-bold text-[15px] text-gray-800 leading-snug">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h3>
|
||||
|
||||
{/* 출처 */}
|
||||
{sourceName && (
|
||||
<div className="flex items-center gap-1 mt-1.5 text-xs text-gray-400">
|
||||
<Link2 size={11} />
|
||||
<span>{sourceName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 멤버 */}
|
||||
{displayMembers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-gray-100">
|
||||
{displayMembers.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2.5 py-1 bg-gradient-to-r from-primary to-primary-dark text-white text-xs rounded-lg font-semibold shadow-sm"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ScheduleListCard;
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Clock, Link2 } from 'lucide-react';
|
||||
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||
|
||||
/**
|
||||
* Mobile용 일정 검색 카드 컴포넌트
|
||||
* 스케줄 페이지의 검색 결과에서 사용
|
||||
* 날짜를 왼쪽에 표시하는 레이아웃
|
||||
*/
|
||||
const ScheduleSearchCard = memo(function ScheduleSearchCard({
|
||||
schedule,
|
||||
onClick,
|
||||
delay = 0,
|
||||
className = '',
|
||||
}) {
|
||||
const scheduleDate = new Date(schedule.date || schedule.datetime);
|
||||
const categoryInfo = getCategoryInfo(schedule);
|
||||
const timeStr = getScheduleTime(schedule);
|
||||
const displayMembers = getDisplayMembers(schedule);
|
||||
const sourceName = schedule.source?.name;
|
||||
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const isSunday = scheduleDate.getDay() === 0;
|
||||
const isSaturday = scheduleDate.getDay() === 6;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay, type: 'spring', stiffness: 300, damping: 30 }}
|
||||
onClick={onClick}
|
||||
className={`cursor-pointer ${className}`}
|
||||
>
|
||||
{/* 카드 본체 */}
|
||||
<div className="relative bg-white rounded-md shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-gray-100/50 overflow-hidden active:bg-gray-50 transition-colors">
|
||||
<div className="flex">
|
||||
{/* 왼쪽 날짜 영역 */}
|
||||
<div className="flex-shrink-0 w-16 py-3 px-2 bg-gray-50 border-r border-gray-100 flex flex-col items-center justify-center">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{scheduleDate.getFullYear()}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-gray-800">
|
||||
{scheduleDate.getMonth() + 1}.{scheduleDate.getDate()}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-medium ${
|
||||
isSunday
|
||||
? 'text-red-500'
|
||||
: isSaturday
|
||||
? 'text-blue-500'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{dayNames[scheduleDate.getDay()]}요일
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 콘텐츠 영역 */}
|
||||
<div className="flex-1 p-4 min-w-0">
|
||||
{/* 시간 및 카테고리 뱃지 */}
|
||||
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
|
||||
{timeStr && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-white text-xs font-medium"
|
||||
style={{ backgroundColor: categoryInfo.color }}
|
||||
>
|
||||
<Clock size={10} />
|
||||
{timeStr}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${categoryInfo.color}15`,
|
||||
color: categoryInfo.color,
|
||||
}}
|
||||
>
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className="font-bold text-[15px] text-gray-800 leading-snug line-clamp-2">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h3>
|
||||
|
||||
{/* 출처 */}
|
||||
{sourceName && (
|
||||
<div className="flex items-center gap-1 mt-1.5 text-xs text-gray-400">
|
||||
<Link2 size={11} />
|
||||
<span>{sourceName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 멤버 */}
|
||||
{displayMembers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-gray-100">
|
||||
{displayMembers.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2.5 py-1 bg-gradient-to-r from-primary to-primary-dark text-white text-xs rounded-lg font-semibold shadow-sm"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ScheduleSearchCard;
|
||||
5
frontend-temp/src/components/mobile/schedule/index.js
Normal file
5
frontend-temp/src/components/mobile/schedule/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { default as Calendar } from './Calendar';
|
||||
export { default as ScheduleCard } from './ScheduleCard';
|
||||
export { default as ScheduleListCard } from './ScheduleListCard';
|
||||
export { default as ScheduleSearchCard } from './ScheduleSearchCard';
|
||||
export { default as BirthdayCard } from './BirthdayCard';
|
||||
211
frontend-temp/src/components/pc/admin/album/BulkEditPanel.jsx
Normal file
211
frontend-temp/src/components/pc/admin/album/BulkEditPanel.jsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* 일괄 편집 패널 컴포넌트
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { Tag, Users, User, Users2, Check } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 범위 문자열 파싱
|
||||
*/
|
||||
export const parseRange = (rangeStr, baseNumber = 1) => {
|
||||
if (!rangeStr.trim()) return [];
|
||||
const indices = new Set();
|
||||
const parts = rangeStr.split(',').map((s) => s.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.includes('-')) {
|
||||
const [start, end] = part.split('-').map((n) => parseInt(n.trim()));
|
||||
if (!isNaN(start) && !isNaN(end)) {
|
||||
for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
|
||||
const idx = i - baseNumber;
|
||||
if (idx >= 0) indices.add(idx);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const num = parseInt(part);
|
||||
if (!isNaN(num)) {
|
||||
const idx = num - baseNumber;
|
||||
if (idx >= 0) indices.add(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(indices).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object} props.bulkEdit - 일괄 편집 상태
|
||||
* @param {Function} props.setBulkEdit - 일괄 편집 상태 설정
|
||||
* @param {number} props.startNumber - 시작 번호
|
||||
* @param {number} props.pendingFilesCount - 대기 파일 수
|
||||
* @param {Array} props.members - 멤버 목록
|
||||
* @param {Function} props.onApply - 적용 핸들러
|
||||
*/
|
||||
const BulkEditPanel = memo(function BulkEditPanel({
|
||||
bulkEdit,
|
||||
setBulkEdit,
|
||||
startNumber,
|
||||
pendingFilesCount,
|
||||
members,
|
||||
onApply,
|
||||
}) {
|
||||
const groupTypes = [
|
||||
{ value: 'group', icon: Users, label: '단체' },
|
||||
{ value: 'solo', icon: User, label: '개인' },
|
||||
{ value: 'unit', icon: Users2, label: '유닛' },
|
||||
];
|
||||
|
||||
const toggleBulkMember = (memberId) => {
|
||||
setBulkEdit((prev) => ({
|
||||
...prev,
|
||||
members: prev.members.includes(memberId)
|
||||
? prev.members.filter((m) => m !== memberId)
|
||||
: [...prev.members, memberId],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-72 flex-shrink-0">
|
||||
<div className="sticky top-24 bg-white rounded-2xl shadow-lg border border-gray-100 p-5">
|
||||
<h3 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Tag size={18} className="text-primary" />
|
||||
일괄 편집
|
||||
</h3>
|
||||
|
||||
{/* 번호 범위 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">번호 범위</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bulkEdit.range}
|
||||
onChange={(e) => setBulkEdit((prev) => ({ ...prev, range: e.target.value }))}
|
||||
placeholder={`예: ${startNumber}-${startNumber + 4}, ${startNumber + 7}`}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{startNumber}~{startNumber + pendingFilesCount - 1}번 중{' '}
|
||||
{parseRange(bulkEdit.range, startNumber).filter((i) => i < pendingFilesCount).length}개
|
||||
선택
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 타입 선택 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">타입</label>
|
||||
<div className="flex gap-1">
|
||||
{groupTypes.map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() =>
|
||||
setBulkEdit((prev) => ({
|
||||
...prev,
|
||||
groupType: prev.groupType === value ? '' : value,
|
||||
members: value === 'group' ? [] : prev.members,
|
||||
}))
|
||||
}
|
||||
className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-1 ${
|
||||
bulkEdit.groupType === value
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 멤버 선택 */}
|
||||
{bulkEdit.groupType !== 'group' && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
멤버 {bulkEdit.groupType === 'solo' ? '(1명)' : '(다중 선택)'}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{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-primary text-white border border-primary'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-100'
|
||||
}`}
|
||||
>
|
||||
{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 border border-gray-500'
|
||||
: 'bg-gray-100 text-gray-400 hover:bg-gray-200 border border-gray-100'
|
||||
}`}
|
||||
>
|
||||
{member.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컨셉명 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">컨셉명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bulkEdit.conceptName}
|
||||
onChange={(e) => setBulkEdit((prev) => ({ ...prev, conceptName: e.target.value }))}
|
||||
placeholder="컨셉명 입력"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<button
|
||||
onClick={onApply}
|
||||
disabled={!bulkEdit.range.trim()}
|
||||
className={`w-full py-2.5 rounded-lg font-medium transition-colors flex items-center justify-center gap-2 ${
|
||||
bulkEdit.range.trim()
|
||||
? 'bg-primary text-white hover:bg-primary/90'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Check size={18} />
|
||||
일괄 적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default BulkEditPanel;
|
||||
214
frontend-temp/src/components/pc/admin/album/PendingFileItem.jsx
Normal file
214
frontend-temp/src/components/pc/admin/album/PendingFileItem.jsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* 업로드 대기 파일 아이템 컴포넌트
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { Reorder } from 'framer-motion';
|
||||
import { GripVertical, Trash2, Users, User, Users2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object} props.file - 파일 데이터
|
||||
* @param {number} props.index - 인덱스
|
||||
* @param {number} props.startNumber - 시작 번호
|
||||
* @param {string} props.photoType - 사진 타입 (concept/teaser)
|
||||
* @param {Array} props.members - 멤버 목록
|
||||
* @param {Function} props.onPreview - 미리보기 핸들러
|
||||
* @param {Function} props.onDelete - 삭제 핸들러
|
||||
* @param {Function} props.onUpdateFile - 파일 업데이트 핸들러
|
||||
* @param {Function} props.onToggleMember - 멤버 토글 핸들러
|
||||
* @param {Function} props.onChangeGroupType - 그룹 타입 변경 핸들러
|
||||
* @param {Function} props.onMoveToPosition - 위치 이동 핸들러
|
||||
* @param {Array} props.pendingFiles - 전체 대기 파일 목록 (위치 계산용)
|
||||
*/
|
||||
const PendingFileItem = memo(function PendingFileItem({
|
||||
file,
|
||||
index,
|
||||
startNumber,
|
||||
photoType,
|
||||
members,
|
||||
onPreview,
|
||||
onDelete,
|
||||
onUpdateFile,
|
||||
onToggleMember,
|
||||
onChangeGroupType,
|
||||
onMoveToPosition,
|
||||
pendingFiles,
|
||||
}) {
|
||||
const groupTypes = [
|
||||
{ value: 'group', icon: Users, label: '단체' },
|
||||
{ value: 'solo', icon: User, label: '개인' },
|
||||
{ value: 'unit', icon: Users2, label: '유닛' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={file}
|
||||
className="bg-gray-50 rounded-xl p-4 cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<div className="flex gap-4 items-center">
|
||||
{/* 드래그 핸들 + 순서 번호 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical size={18} className="text-gray-300" />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
defaultValue={String(startNumber + index).padStart(2, '0')}
|
||||
key={`order-${file.id}-${index}-${startNumber}`}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim();
|
||||
const scrollY = window.scrollY;
|
||||
const currentIndex = pendingFiles.findIndex((f) => f.id === file.id);
|
||||
const currentOrder = startNumber + currentIndex;
|
||||
|
||||
if (val && !isNaN(val) && parseInt(val) !== currentOrder) {
|
||||
onMoveToPosition(file.id, val);
|
||||
}
|
||||
|
||||
const newIndex = pendingFiles.findIndex((f) => f.id === file.id);
|
||||
e.target.value = String(startNumber + newIndex).padStart(2, '0');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, scrollY);
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.target.blur();
|
||||
}
|
||||
}}
|
||||
className="w-10 h-8 bg-primary/10 text-primary rounded-lg text-center text-sm font-bold border-0 focus:outline-none focus:ring-2 focus:ring-primary [appearance:textfield]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 썸네일 */}
|
||||
{file.isVideo ? (
|
||||
<div className="relative w-[180px] h-[180px] flex-shrink-0">
|
||||
<video
|
||||
src={file.preview}
|
||||
className="w-full h-full rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity select-none"
|
||||
onClick={() => onPreview(file)}
|
||||
muted
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/30 rounded-lg cursor-pointer"
|
||||
onClick={() => onPreview(file)}
|
||||
>
|
||||
<div className="w-12 h-12 bg-white/90 rounded-full flex items-center justify-center">
|
||||
<div className="w-0 h-0 border-l-[14px] border-l-gray-800 border-y-[8px] border-y-transparent ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={file.preview}
|
||||
alt={file.filename}
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
className="w-[180px] h-[180px] rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0 select-none"
|
||||
onClick={() => onPreview(file)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="flex-1 space-y-3 h-[200px] overflow-hidden">
|
||||
<p className="text-base font-medium text-gray-900 truncate">{file.filename}</p>
|
||||
|
||||
{photoType === 'concept' && (
|
||||
<>
|
||||
{/* 단체/솔로/유닛 선택 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 w-16">타입:</span>
|
||||
<div className="flex gap-1.5">
|
||||
{groupTypes.map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => onChangeGroupType(file.id, value)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
file.groupType === value
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 멤버 태깅 */}
|
||||
<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
|
||||
.filter((m) => !m.is_former)
|
||||
.map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
onClick={() => onToggleMember(file.id, member.id)}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
file.members.includes(member.id)
|
||||
? 'bg-primary text-white border border-primary'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{member.name}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</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={() => onToggleMember(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 border border-gray-500'
|
||||
: '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">
|
||||
<span className="text-sm text-gray-500 w-16">컨셉명:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={file.conceptName}
|
||||
onChange={(e) => onUpdateFile(file.id, 'conceptName', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder="컨셉명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
onClick={() => onDelete(file.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors self-start"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
});
|
||||
|
||||
export default PendingFileItem;
|
||||
142
frontend-temp/src/components/pc/admin/album/PhotoGrid.jsx
Normal file
142
frontend-temp/src/components/pc/admin/album/PhotoGrid.jsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* 사진/티저 그리드 컴포넌트
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Image, Check } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Array} props.items - 사진/티저 목록
|
||||
* @param {Array} props.selectedItems - 선택된 아이템 ID 목록
|
||||
* @param {Function} props.onToggleSelect - 선택 토글 핸들러
|
||||
* @param {'concept'|'teaser'} props.type - 그리드 타입
|
||||
*/
|
||||
const PhotoGrid = memo(function PhotoGrid({ items, selectedItems, onToggleSelect, type }) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<Image className="mx-auto text-gray-300 mb-4" size={48} />
|
||||
<p className="text-gray-500">
|
||||
등록된 {type === 'concept' ? '컨셉 포토' : '티저 이미지'}가 없습니다
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
업로드 탭에서 {type === 'concept' ? '사진' : '티저'}을 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'concept') {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
{items.map((photo, index) => (
|
||||
<motion.div
|
||||
key={photo.id}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.2, delay: index < 20 ? index * 0.02 : 0 }}
|
||||
className={`relative group aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all duration-200 ${
|
||||
selectedItems.includes(photo.id)
|
||||
? 'border-primary ring-2 ring-primary/30 scale-[0.98]'
|
||||
: 'border-transparent hover:border-primary/50 hover:shadow-lg'
|
||||
}`}
|
||||
onClick={() => onToggleSelect(photo.id)}
|
||||
>
|
||||
<img
|
||||
src={photo.thumb_url || photo.medium_url}
|
||||
alt={`사진 ${photo.sort_order}`}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
|
||||
|
||||
<div
|
||||
className={`absolute top-2 left-2 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
||||
selectedItems.includes(photo.id)
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-white/80 border-gray-300 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{selectedItems.includes(photo.id) && <Check size={14} className="text-white" />}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 px-2 py-0.5 bg-black/60 rounded text-white text-xs font-medium">
|
||||
{String(photo.sort_order).padStart(2, '0')}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
|
||||
{photo.concept_name && (
|
||||
<span className="text-white text-xs font-medium truncate block">
|
||||
{photo.concept_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Teaser grid
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
{items.map((teaser, index) => {
|
||||
const teaserId = `teaser-${teaser.id}`;
|
||||
return (
|
||||
<motion.div
|
||||
key={teaser.id}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.2, delay: index < 20 ? index * 0.02 : 0 }}
|
||||
className={`relative group aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all duration-200 ${
|
||||
selectedItems.includes(teaserId)
|
||||
? 'border-primary ring-2 ring-primary/30 scale-[0.98]'
|
||||
: 'border-transparent hover:border-primary/50 hover:shadow-lg'
|
||||
}`}
|
||||
onClick={() => onToggleSelect(teaserId)}
|
||||
>
|
||||
{teaser.media_type === 'video' ? (
|
||||
<video
|
||||
src={teaser.video_url || teaser.original_url}
|
||||
poster={teaser.thumb_url}
|
||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
muted
|
||||
loop
|
||||
onMouseEnter={(e) => e.target.play()}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.pause();
|
||||
e.target.currentTime = 0;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={teaser.thumb_url || teaser.medium_url}
|
||||
alt={`티저 ${teaser.sort_order}`}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
|
||||
|
||||
<div
|
||||
className={`absolute top-2 left-2 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
||||
selectedItems.includes(teaserId)
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-white/80 border-gray-300 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{selectedItems.includes(teaserId) && <Check size={14} className="text-white" />}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 px-2 py-0.5 bg-purple-600/80 rounded text-white text-xs font-medium">
|
||||
{String(teaser.sort_order).padStart(2, '0')}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PhotoGrid;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* 사진/비디오 미리보기 모달 컴포넌트
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object|null} props.photo - 미리보기할 사진/비디오 객체
|
||||
* @param {Function} props.onClose - 닫기 핸들러
|
||||
*/
|
||||
const PhotoPreviewModal = memo(function PhotoPreviewModal({ photo, onClose }) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{photo && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
{photo.isVideo ? (
|
||||
<motion.video
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.9 }}
|
||||
src={photo.preview || photo.url}
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
controls
|
||||
autoPlay
|
||||
/>
|
||||
) : (
|
||||
<motion.img
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.9 }}
|
||||
src={photo.preview || photo.url}
|
||||
alt={photo.filename}
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default PhotoPreviewModal;
|
||||
153
frontend-temp/src/components/pc/admin/album/TrackItem.jsx
Normal file
153
frontend-temp/src/components/pc/admin/album/TrackItem.jsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* 앨범 트랙 입력 컴포넌트
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Trash2, Star, ChevronDown } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object} props.track - 트랙 데이터
|
||||
* @param {number} props.index - 트랙 인덱스
|
||||
* @param {Function} props.onUpdate - 트랙 업데이트 핸들러 (index, field, value)
|
||||
* @param {Function} props.onRemove - 트랙 삭제 핸들러 ()
|
||||
*/
|
||||
const TrackItem = memo(function TrackItem({ track, index, onUpdate, onRemove }) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center text-sm font-medium text-gray-600">
|
||||
{String(track.track_number).padStart(2, '0')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdate(index, 'is_title_track', !track.is_title_track)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
track.is_title_track
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Star size={12} fill={track.is_title_track ? 'currentColor' : 'none'} />
|
||||
타이틀
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="col-span-3">
|
||||
<input
|
||||
type="text"
|
||||
value={track.title}
|
||||
onChange={(e) => onUpdate(index, 'title', 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"
|
||||
placeholder="곡 제목"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={track.duration || ''}
|
||||
onChange={(e) => onUpdate(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"
|
||||
placeholder="0:00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 토글 */}
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdate(index, 'showDetails', !track.showDetails)}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform ${track.showDetails ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
상세 정보 {track.showDetails ? '접기' : '펼치기'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{track.showDetails && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{/* 작사/작곡/편곡 */}
|
||||
<div className="space-y-3 mt-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">작사</label>
|
||||
<input
|
||||
type="text"
|
||||
value={track.lyricist || ''}
|
||||
onChange={(e) => onUpdate(index, 'lyricist', 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"
|
||||
placeholder="여러 명일 경우 쉼표로 구분"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">작곡</label>
|
||||
<input
|
||||
type="text"
|
||||
value={track.composer || ''}
|
||||
onChange={(e) => onUpdate(index, 'composer', 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"
|
||||
placeholder="여러 명일 경우 쉼표로 구분"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">편곡</label>
|
||||
<input
|
||||
type="text"
|
||||
value={track.arranger || ''}
|
||||
onChange={(e) => onUpdate(index, 'arranger', 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"
|
||||
placeholder="여러 명일 경우 쉼표로 구분"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MV URL */}
|
||||
<div className="mt-3">
|
||||
<label className="block text-xs text-gray-500 mb-1">뮤직비디오 URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={track.music_video_url || ''}
|
||||
onChange={(e) => onUpdate(index, 'music_video_url', 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"
|
||||
placeholder="https://youtube.com/watch?v=..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 가사 */}
|
||||
<div className="mt-3">
|
||||
<label className="block text-xs text-gray-500 mb-1">가사</label>
|
||||
<textarea
|
||||
value={track.lyrics || ''}
|
||||
onChange={(e) => onUpdate(index, 'lyrics', e.target.value)}
|
||||
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-none min-h-[200px]"
|
||||
placeholder="가사를 입력하세요..."
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TrackItem;
|
||||
5
frontend-temp/src/components/pc/admin/album/index.js
Normal file
5
frontend-temp/src/components/pc/admin/album/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { default as TrackItem } from './TrackItem';
|
||||
export { default as PendingFileItem } from './PendingFileItem';
|
||||
export { default as BulkEditPanel, parseRange } from './BulkEditPanel';
|
||||
export { default as PhotoGrid } from './PhotoGrid';
|
||||
export { default as PhotoPreviewModal } from './PhotoPreviewModal';
|
||||
233
frontend-temp/src/components/pc/admin/bot/BotCard.jsx
Normal file
233
frontend-temp/src/components/pc/admin/bot/BotCard.jsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* 봇 카드 컴포넌트
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Youtube, Play, Square, RefreshCw, Download } from 'lucide-react';
|
||||
|
||||
// X 아이콘 컴포넌트
|
||||
export const XIcon = ({ size = 20, fill = 'currentColor' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Meilisearch 아이콘 컴포넌트
|
||||
export const MeilisearchIcon = ({ size = 20 }) => (
|
||||
<svg width={size} height={size} viewBox="0 108.4 512 295.2">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="meili-a"
|
||||
x1="488.157"
|
||||
x2="-21.055"
|
||||
y1="469.917"
|
||||
y2="179.001"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="meili-b"
|
||||
x1="522.305"
|
||||
x2="13.094"
|
||||
y1="410.144"
|
||||
y2="119.228"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="meili-c"
|
||||
x1="556.456"
|
||||
x2="47.244"
|
||||
y1="350.368"
|
||||
y2="59.452"
|
||||
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#ff5caa" />
|
||||
<stop offset="1" stopColor="#ff4e62" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="m0 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-a)"
|
||||
/>
|
||||
<path
|
||||
d="m138.8 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-b)"
|
||||
/>
|
||||
<path
|
||||
d="m277.6 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||
fill="url(#meili-c)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object} props.bot - 봇 데이터
|
||||
* @param {number} props.index - 인덱스 (애니메이션용)
|
||||
* @param {boolean} props.isInitialLoad - 첫 로드 여부
|
||||
* @param {string|null} props.syncing - 동기화 중인 봇 ID
|
||||
* @param {Object} props.statusInfo - 상태 정보 (text, color, bg, dot)
|
||||
* @param {Function} props.onSync - 동기화 핸들러
|
||||
* @param {Function} props.onToggle - 토글 핸들러
|
||||
* @param {Function} props.onAnimationComplete - 애니메이션 완료 핸들러
|
||||
* @param {Function} props.formatTime - 시간 포맷 함수
|
||||
* @param {Function} props.formatInterval - 간격 포맷 함수
|
||||
*/
|
||||
const BotCard = memo(function BotCard({
|
||||
bot,
|
||||
index,
|
||||
isInitialLoad,
|
||||
syncing,
|
||||
statusInfo,
|
||||
onSync,
|
||||
onToggle,
|
||||
onAnimationComplete,
|
||||
formatTime,
|
||||
formatInterval,
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={isInitialLoad ? { opacity: 0, scale: 0.95 } : false}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={isInitialLoad ? { delay: index * 0.05 } : { duration: 0.15 }}
|
||||
onAnimationComplete={onAnimationComplete}
|
||||
className="relative bg-gradient-to-br from-gray-50 to-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-all"
|
||||
>
|
||||
{/* 상단 헤더 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
bot.type === 'x'
|
||||
? 'bg-black'
|
||||
: bot.type === 'meilisearch'
|
||||
? 'bg-[#ddf1fd]'
|
||||
: 'bg-red-50'
|
||||
}`}
|
||||
>
|
||||
{bot.type === 'x' ? (
|
||||
<XIcon size={20} fill="white" />
|
||||
) : bot.type === 'meilisearch' ? (
|
||||
<MeilisearchIcon size={20} />
|
||||
) : (
|
||||
<Youtube size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{bot.name}</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
{bot.last_check_at
|
||||
? `${formatTime(bot.last_check_at)}에 업데이트됨`
|
||||
: '아직 업데이트 없음'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.color}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`}
|
||||
></span>
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
|
||||
{bot.type === 'meilisearch' ? (
|
||||
<>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{bot.schedules_added || 0}</div>
|
||||
<div className="text-xs text-gray-400">동기화 수</div>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{bot.last_added_count ? `${(bot.last_added_count / 1000 || 0).toFixed(1)}초` : '-'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">소요 시간</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{bot.schedules_added}</div>
|
||||
<div className="text-xs text-gray-400">총 추가</div>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<div
|
||||
className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}
|
||||
>
|
||||
+{bot.last_added_count || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">마지막</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{formatInterval(bot.check_interval)}</div>
|
||||
<div className="text-xs text-gray-400">업데이트 간격</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{bot.status === 'error' && bot.error_message && (
|
||||
<div className="px-4 py-2 bg-red-50 text-red-600 text-xs border-t border-red-100">
|
||||
{bot.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onSync(bot.id)}
|
||||
disabled={syncing === bot.id}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium transition-colors hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{syncing === bot.id ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
<span>동기화 중...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={16} />
|
||||
<span>전체 동기화</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggle(bot.id, bot.status, bot.name)}
|
||||
className={`flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
|
||||
bot.status === 'running'
|
||||
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
: 'bg-green-500 text-white hover:bg-green-600'
|
||||
}`}
|
||||
>
|
||||
{bot.status === 'running' ? (
|
||||
<>
|
||||
<Square size={16} />
|
||||
<span>정지</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} />
|
||||
<span>시작</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default BotCard;
|
||||
1
frontend-temp/src/components/pc/admin/bot/index.js
Normal file
1
frontend-temp/src/components/pc/admin/bot/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as BotCard, XIcon, MeilisearchIcon } from './BotCard';
|
||||
115
frontend-temp/src/components/pc/admin/common/ConfirmDialog.jsx
Normal file
115
frontend-temp/src/components/pc/admin/common/ConfirmDialog.jsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* ConfirmDialog 컴포넌트
|
||||
* 삭제 등 위험한 작업의 확인을 위한 공통 다이얼로그
|
||||
*
|
||||
* Props:
|
||||
* - isOpen: 다이얼로그 표시 여부
|
||||
* - onClose: 닫기 콜백
|
||||
* - onConfirm: 확인 콜백
|
||||
* - title: 제목 (예: "앨범 삭제")
|
||||
* - message: 메시지 내용 (ReactNode 가능)
|
||||
* - confirmText: 확인 버튼 텍스트 (기본: "삭제")
|
||||
* - cancelText: 취소 버튼 텍스트 (기본: "취소")
|
||||
* - loading: 로딩 상태
|
||||
* - loadingText: 로딩 중 텍스트 (기본: "삭제 중...")
|
||||
* - variant: 버튼 색상 (기본: "danger", "primary" 가능)
|
||||
*/
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
|
||||
function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = '삭제',
|
||||
cancelText = '취소',
|
||||
loading = false,
|
||||
loadingText = '삭제 중...',
|
||||
variant = 'danger',
|
||||
icon: Icon = AlertTriangle,
|
||||
}) {
|
||||
// 버튼 색상 설정
|
||||
const buttonColors = {
|
||||
danger: 'bg-red-500 hover:bg-red-600',
|
||||
primary: 'bg-primary hover:bg-primary-dark',
|
||||
};
|
||||
|
||||
const iconBgColors = {
|
||||
danger: 'bg-red-100',
|
||||
primary: 'bg-primary/10',
|
||||
};
|
||||
|
||||
const iconColors = {
|
||||
danger: 'text-red-500',
|
||||
primary: 'text-primary',
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={() => !loading && onClose()}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full ${iconBgColors[variant]} flex items-center justify-center`}
|
||||
>
|
||||
<Icon className={iconColors[variant]} size={20} />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
|
||||
{/* 메시지 */}
|
||||
<div className="text-gray-600 mb-6">{message}</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={`px-4 py-2 ${buttonColors[variant]} text-white rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 size={16} />
|
||||
{confirmText}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmDialog;
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* 커스텀 드롭다운 셀렉트 컴포넌트
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {string} props.value - 선택된 값
|
||||
* @param {Function} props.onChange - 값 변경 핸들러
|
||||
* @param {string[]} props.options - 옵션 목록
|
||||
* @param {string} props.placeholder - 플레이스홀더
|
||||
*/
|
||||
function CustomSelect({ value, onChange, options, placeholder }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>{value || placeholder}</span>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||
value === option ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomSelect;
|
||||
336
frontend-temp/src/components/pc/admin/common/DatePicker.jsx
Normal file
336
frontend-temp/src/components/pc/admin/common/DatePicker.jsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
/**
|
||||
* DatePicker 컴포넌트
|
||||
* 연/월/일 선택이 가능한 드롭다운 형태의 날짜 선택기
|
||||
*
|
||||
* @param {string} value - 선택된 날짜 (YYYY-MM-DD 형식)
|
||||
* @param {function} onChange - 날짜 변경 콜백
|
||||
* @param {string} placeholder - 플레이스홀더 텍스트
|
||||
* @param {boolean} showDayOfWeek - 요일 표시 여부
|
||||
* @param {number} minYear - 선택 가능한 최소 연도 (기본값: 2000)
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Calendar, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
|
||||
function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '날짜 선택',
|
||||
showDayOfWeek = false,
|
||||
minYear = 2000,
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('days');
|
||||
const [viewDate, setViewDate] = useState(() => {
|
||||
if (value) return new Date(value);
|
||||
return new Date();
|
||||
});
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
setViewMode('days');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// value가 변경되면 viewDate도 업데이트
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setViewDate(new Date(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i);
|
||||
}
|
||||
|
||||
// 연도 범위 계산 (minYear 기준으로 12개씩 그룹)
|
||||
const groupIndex = Math.floor((year - minYear) / 12);
|
||||
const startYear = minYear + groupIndex * 12;
|
||||
const years = Array.from({ length: 12 }, (_, i) => startYear + i);
|
||||
const canGoPrevYearRange = startYear > minYear;
|
||||
|
||||
const handleButtonClick = (e, callback) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
callback();
|
||||
};
|
||||
|
||||
const prevMonth = () => {
|
||||
const newDate = new Date(year, month - 1, 1);
|
||||
if (newDate.getFullYear() >= minYear) {
|
||||
setViewDate(newDate);
|
||||
}
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
setViewDate(new Date(year, month + 1, 1));
|
||||
};
|
||||
|
||||
const prevYearRange = () => {
|
||||
if (canGoPrevYearRange) {
|
||||
setViewDate(new Date(startYear - 12, month, 1));
|
||||
}
|
||||
};
|
||||
|
||||
const nextYearRange = () => {
|
||||
setViewDate(new Date(startYear + 12, month, 1));
|
||||
};
|
||||
|
||||
const selectDate = (day) => {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
onChange(dateStr);
|
||||
setIsOpen(false);
|
||||
setViewMode('days');
|
||||
};
|
||||
|
||||
const selectYear = (y) => {
|
||||
setViewDate(new Date(y, month, 1));
|
||||
};
|
||||
|
||||
const selectMonth = (m) => {
|
||||
setViewDate(new Date(year, m, 1));
|
||||
setViewMode('days');
|
||||
};
|
||||
|
||||
const formatDisplayDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const [y, m, d] = dateStr.split('-');
|
||||
if (showDayOfWeek) {
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const date = new Date(parseInt(y), parseInt(m) - 1, parseInt(d));
|
||||
const dayOfWeek = dayNames[date.getDay()];
|
||||
return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일 (${dayOfWeek})`;
|
||||
}
|
||||
return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일`;
|
||||
};
|
||||
|
||||
const isSelected = (day) => {
|
||||
if (!value || !day) return false;
|
||||
const [y, m, d] = value.split('-');
|
||||
return parseInt(y) === year && parseInt(m) === month + 1 && parseInt(d) === day;
|
||||
};
|
||||
|
||||
const isToday = (day) => {
|
||||
if (!day) return false;
|
||||
const today = new Date();
|
||||
return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth();
|
||||
const isCurrentYear = (y) => currentYear === y;
|
||||
const isCurrentMonth = (m) => currentYear === year && currentMonth === m;
|
||||
|
||||
const monthNames = [
|
||||
'1월',
|
||||
'2월',
|
||||
'3월',
|
||||
'4월',
|
||||
'5월',
|
||||
'6월',
|
||||
'7월',
|
||||
'8월',
|
||||
'9월',
|
||||
'10월',
|
||||
'11월',
|
||||
'12월',
|
||||
];
|
||||
|
||||
// 연도 버튼 클래스
|
||||
const getYearButtonClass = (y) => {
|
||||
if (year === y) {
|
||||
return 'bg-primary text-white';
|
||||
}
|
||||
if (isCurrentYear(y)) {
|
||||
return 'text-primary font-medium hover:bg-gray-100';
|
||||
}
|
||||
return 'text-gray-700 hover:bg-gray-100';
|
||||
};
|
||||
|
||||
// 월 버튼 클래스
|
||||
const getMonthButtonClass = (m) => {
|
||||
if (month === m) {
|
||||
return 'bg-primary text-white';
|
||||
}
|
||||
if (isCurrentMonth(m)) {
|
||||
return 'text-primary font-medium hover:bg-gray-100';
|
||||
}
|
||||
return 'text-gray-700 hover:bg-gray-100';
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleButtonClick(e, () => setIsOpen(!isOpen))}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{value ? formatDisplayDate(value) : placeholder}
|
||||
</span>
|
||||
<Calendar size={18} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-50 mt-2 bg-white border border-gray-200 rounded-xl shadow-lg p-4 w-80"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) =>
|
||||
handleButtonClick(e, viewMode === 'years' ? prevYearRange : prevMonth)
|
||||
}
|
||||
disabled={
|
||||
viewMode === 'years'
|
||||
? !canGoPrevYearRange
|
||||
: year === minYear && month === 0
|
||||
}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
(viewMode === 'years' && !canGoPrevYearRange) ||
|
||||
(viewMode !== 'years' && year === minYear && month === 0)
|
||||
? 'opacity-30 cursor-not-allowed'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft size={20} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) =>
|
||||
handleButtonClick(e, () => setViewMode(viewMode === 'days' ? 'years' : 'days'))
|
||||
}
|
||||
className="font-medium text-gray-900 hover:text-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
{viewMode === 'years'
|
||||
? `${years[0]} - ${years[years.length - 1]}`
|
||||
: `${year}년 ${month + 1}월`}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform ${viewMode !== 'days' ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) =>
|
||||
handleButtonClick(e, viewMode === 'years' ? nextYearRange : nextMonth)
|
||||
}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{viewMode === 'years' && (
|
||||
<motion.div
|
||||
key="years"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="text-center text-sm text-gray-500 mb-3">년도</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
{years.map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
onClick={(e) => handleButtonClick(e, () => selectYear(y))}
|
||||
className={`py-2 rounded-lg text-sm transition-colors ${getYearButtonClass(y)}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center text-sm text-gray-500 mb-3">월</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{monthNames.map((m, i) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={(e) => handleButtonClick(e, () => selectMonth(i))}
|
||||
className={`py-2 rounded-lg text-sm transition-colors ${getMonthButtonClass(i)}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{viewMode === 'days' && (
|
||||
<motion.div
|
||||
key="days"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
|
||||
<div
|
||||
key={d}
|
||||
className={`text-center text-xs font-medium py-1 ${
|
||||
i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day, i) => {
|
||||
const dayOfWeek = i % 7;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={!day}
|
||||
onClick={(e) => day && handleButtonClick(e, () => selectDate(day))}
|
||||
className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all
|
||||
${!day ? '' : 'hover:bg-gray-100'}
|
||||
${isSelected(day) ? 'bg-primary text-white hover:bg-primary' : ''}
|
||||
${isToday(day) && !isSelected(day) ? 'text-primary font-bold' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 0 ? 'text-red-500' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 6 ? 'text-blue-500' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek > 0 && dayOfWeek < 6 ? 'text-gray-700' : ''}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DatePicker;
|
||||
188
frontend-temp/src/components/pc/admin/common/NumberPicker.jsx
Normal file
188
frontend-temp/src/components/pc/admin/common/NumberPicker.jsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* NumberPicker 컴포넌트
|
||||
* 스크롤 가능한 숫자/값 선택 피커
|
||||
* AdminScheduleForm의 시간 선택에서 사용
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
function NumberPicker({ items, value, onChange }) {
|
||||
const ITEM_HEIGHT = 40;
|
||||
const containerRef = useRef(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const offsetRef = useRef(0); // 드래그용 ref
|
||||
const touchStartY = useRef(0);
|
||||
const startOffset = useRef(0);
|
||||
const isScrolling = useRef(false);
|
||||
|
||||
// offset 변경시 ref도 업데이트
|
||||
useEffect(() => {
|
||||
offsetRef.current = offset;
|
||||
}, [offset]);
|
||||
|
||||
// 초기 위치 설정
|
||||
useEffect(() => {
|
||||
if (value !== null && value !== undefined) {
|
||||
const index = items.indexOf(value);
|
||||
if (index !== -1) {
|
||||
const newOffset = -index * ITEM_HEIGHT;
|
||||
setOffset(newOffset);
|
||||
offsetRef.current = newOffset;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 값 변경시 위치 업데이트
|
||||
useEffect(() => {
|
||||
const index = items.indexOf(value);
|
||||
if (index !== -1) {
|
||||
const targetOffset = -index * ITEM_HEIGHT;
|
||||
if (Math.abs(offset - targetOffset) > 1) {
|
||||
setOffset(targetOffset);
|
||||
offsetRef.current = targetOffset;
|
||||
}
|
||||
}
|
||||
}, [value, items]);
|
||||
|
||||
const centerOffset = ITEM_HEIGHT; // 중앙 위치 오프셋
|
||||
|
||||
// 아이템이 중앙에 있는지 확인
|
||||
const isItemInCenter = (item) => {
|
||||
const itemIndex = items.indexOf(item);
|
||||
const itemPosition = -itemIndex * ITEM_HEIGHT;
|
||||
const tolerance = ITEM_HEIGHT / 2;
|
||||
return Math.abs(offset - itemPosition) < tolerance;
|
||||
};
|
||||
|
||||
// 오프셋 업데이트 (경계 제한)
|
||||
const updateOffset = (newOffset) => {
|
||||
const maxOffset = 0;
|
||||
const minOffset = -(items.length - 1) * ITEM_HEIGHT;
|
||||
return Math.min(maxOffset, Math.max(minOffset, newOffset));
|
||||
};
|
||||
|
||||
// 중앙 아이템 업데이트
|
||||
const updateCenterItem = (currentOffset) => {
|
||||
const centerIndex = Math.round(-currentOffset / ITEM_HEIGHT);
|
||||
if (centerIndex >= 0 && centerIndex < items.length) {
|
||||
const centerItem = items[centerIndex];
|
||||
if (value !== centerItem) {
|
||||
onChange(centerItem);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 가장 가까운 아이템에 스냅
|
||||
const snapToClosestItem = (currentOffset) => {
|
||||
const targetOffset = Math.round(currentOffset / ITEM_HEIGHT) * ITEM_HEIGHT;
|
||||
setOffset(targetOffset);
|
||||
offsetRef.current = targetOffset;
|
||||
updateCenterItem(targetOffset);
|
||||
};
|
||||
|
||||
// 터치 시작
|
||||
const handleTouchStart = (e) => {
|
||||
e.stopPropagation();
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
startOffset.current = offsetRef.current;
|
||||
};
|
||||
|
||||
// 터치 이동
|
||||
const handleTouchMove = (e) => {
|
||||
e.stopPropagation();
|
||||
const touchY = e.touches[0].clientY;
|
||||
const deltaY = touchY - touchStartY.current;
|
||||
const newOffset = updateOffset(startOffset.current + deltaY);
|
||||
setOffset(newOffset);
|
||||
offsetRef.current = newOffset;
|
||||
};
|
||||
|
||||
// 터치 종료
|
||||
const handleTouchEnd = (e) => {
|
||||
e.stopPropagation();
|
||||
snapToClosestItem(offsetRef.current);
|
||||
};
|
||||
|
||||
// 마우스 휠 - 바깥 스크롤 방지
|
||||
const handleWheel = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isScrolling.current) return;
|
||||
isScrolling.current = true;
|
||||
|
||||
const newOffset = updateOffset(offsetRef.current - Math.sign(e.deltaY) * ITEM_HEIGHT);
|
||||
setOffset(newOffset);
|
||||
offsetRef.current = newOffset;
|
||||
snapToClosestItem(newOffset);
|
||||
|
||||
setTimeout(() => {
|
||||
isScrolling.current = false;
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// 마우스 드래그
|
||||
const handleMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
touchStartY.current = e.clientY;
|
||||
startOffset.current = offsetRef.current;
|
||||
|
||||
const handleMouseMove = (moveEvent) => {
|
||||
moveEvent.preventDefault();
|
||||
const deltaY = moveEvent.clientY - touchStartY.current;
|
||||
const newOffset = updateOffset(startOffset.current + deltaY);
|
||||
setOffset(newOffset);
|
||||
offsetRef.current = newOffset;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
snapToClosestItem(offsetRef.current);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// wheel 이벤트 passive false로 등록
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheel);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-16 h-[120px] overflow-hidden touch-none select-none cursor-grab active:cursor-grabbing"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{/* 중앙 선택 영역 */}
|
||||
<div className="absolute top-1/2 left-1 right-1 h-10 -translate-y-1/2 bg-primary/10 rounded-lg z-0" />
|
||||
|
||||
{/* 피커 내부 */}
|
||||
<div
|
||||
className="relative transition-transform duration-150 ease-out"
|
||||
style={{ transform: `translateY(${offset + centerOffset}px)` }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className={`h-10 leading-10 text-center select-none transition-all duration-150 ${
|
||||
isItemInCenter(item) ? 'text-primary text-lg font-bold' : 'text-gray-300 text-base'
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberPicker;
|
||||
157
frontend-temp/src/components/pc/admin/common/TimePicker.jsx
Normal file
157
frontend-temp/src/components/pc/admin/common/TimePicker.jsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* TimePicker 컴포넌트
|
||||
* 오전/오후, 시간, 분을 선택할 수 있는 시간 피커
|
||||
* NumberPicker를 사용하여 스크롤 방식 선택 제공
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Clock } from 'lucide-react';
|
||||
import NumberPicker from './NumberPicker';
|
||||
|
||||
function TimePicker({ value, onChange, placeholder = '시간 선택' }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
// 현재 값 파싱
|
||||
const parseValue = () => {
|
||||
if (!value) return { hour: '12', minute: '00', period: '오후' };
|
||||
const [h, m] = value.split(':');
|
||||
const hour = parseInt(h);
|
||||
const isPM = hour >= 12;
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
return {
|
||||
hour: String(hour12).padStart(2, '0'),
|
||||
minute: m,
|
||||
period: isPM ? '오후' : '오전',
|
||||
};
|
||||
};
|
||||
|
||||
const parsed = parseValue();
|
||||
const [selectedHour, setSelectedHour] = useState(parsed.hour);
|
||||
const [selectedMinute, setSelectedMinute] = useState(parsed.minute);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(parsed.period);
|
||||
|
||||
// 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 피커 열릴 때 현재 값으로 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const parsed = parseValue();
|
||||
setSelectedHour(parsed.hour);
|
||||
setSelectedMinute(parsed.minute);
|
||||
setSelectedPeriod(parsed.period);
|
||||
}
|
||||
}, [isOpen, value]);
|
||||
|
||||
// 시간 확정
|
||||
const handleSave = () => {
|
||||
let hour = parseInt(selectedHour);
|
||||
if (selectedPeriod === '오후' && hour !== 12) hour += 12;
|
||||
if (selectedPeriod === '오전' && hour === 12) hour = 0;
|
||||
const timeStr = `${String(hour).padStart(2, '0')}:${selectedMinute}`;
|
||||
onChange(timeStr);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleClear = () => {
|
||||
onChange('');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// 표시용 포맷
|
||||
const displayValue = () => {
|
||||
if (!value) return placeholder;
|
||||
const [h, m] = value.split(':');
|
||||
const hour = parseInt(h);
|
||||
const isPM = hour >= 12;
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
return `${isPM ? '오후' : '오전'} ${hour12}:${m}`;
|
||||
};
|
||||
|
||||
// 피커 아이템 데이터
|
||||
const periods = ['오전', '오후'];
|
||||
const hours = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12'];
|
||||
const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>{displayValue()}</span>
|
||||
<Clock size={18} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="absolute top-full left-0 mt-2 bg-white rounded-2xl shadow-xl border border-gray-200 z-50 overflow-hidden"
|
||||
>
|
||||
{/* 피커 영역 */}
|
||||
<div className="flex items-center justify-center px-4 py-4">
|
||||
{/* 오전/오후 (맨 앞) */}
|
||||
<NumberPicker items={periods} value={selectedPeriod} onChange={setSelectedPeriod} />
|
||||
|
||||
{/* 시간 */}
|
||||
<NumberPicker items={hours} value={selectedHour} onChange={setSelectedHour} />
|
||||
|
||||
<span className="text-xl text-gray-300 font-medium mx-0.5">:</span>
|
||||
|
||||
{/* 분 */}
|
||||
<NumberPicker items={minutes} value={selectedMinute} onChange={setSelectedMinute} />
|
||||
</div>
|
||||
|
||||
{/* 푸터 버튼 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-1.5 text-sm text-gray-600 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="px-4 py-1.5 text-sm bg-primary text-white font-medium rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimePicker;
|
||||
5
frontend-temp/src/components/pc/admin/common/index.js
Normal file
5
frontend-temp/src/components/pc/admin/common/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { default as ConfirmDialog } from './ConfirmDialog';
|
||||
export { default as CustomSelect } from './CustomSelect';
|
||||
export { default as DatePicker } from './DatePicker';
|
||||
export { default as TimePicker } from './TimePicker';
|
||||
export { default as NumberPicker } from './NumberPicker';
|
||||
14
frontend-temp/src/components/pc/admin/index.js
Normal file
14
frontend-temp/src/components/pc/admin/index.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// 레이아웃
|
||||
export * from './layout';
|
||||
|
||||
// 공통 컴포넌트
|
||||
export * from './common';
|
||||
|
||||
// 스케줄 관련
|
||||
export * from './schedule';
|
||||
|
||||
// 앨범 관련
|
||||
export * from './album';
|
||||
|
||||
// 봇 관련
|
||||
export * from './bot';
|
||||
48
frontend-temp/src/components/pc/admin/layout/Header.jsx
Normal file
48
frontend-temp/src/components/pc/admin/layout/Header.jsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* AdminHeader 컴포넌트
|
||||
* 모든 Admin 페이지에서 공통으로 사용하는 헤더
|
||||
* 로고, Admin 배지, 사용자 정보, 로그아웃 버튼 포함
|
||||
*/
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
function AdminHeader({ user }) {
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/admin/dashboard"
|
||||
className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity"
|
||||
>
|
||||
fromis_9
|
||||
</Link>
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 text-sm">
|
||||
안녕하세요, <span className="text-gray-900 font-medium">{user?.username}</span>님
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminHeader;
|
||||
25
frontend-temp/src/components/pc/admin/layout/Layout.jsx
Normal file
25
frontend-temp/src/components/pc/admin/layout/Layout.jsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* AdminLayout 컴포넌트
|
||||
* 모든 Admin 페이지에서 공통으로 사용하는 레이아웃
|
||||
* 헤더 고정 + 본문 스크롤 구조
|
||||
*/
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
|
||||
function AdminLayout({ user, children }) {
|
||||
const location = useLocation();
|
||||
|
||||
// 일정 관리 페이지는 내부 스크롤 처리
|
||||
const isSchedulePage = location.pathname.includes('/admin/schedule');
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden flex flex-col bg-gray-50">
|
||||
<Header user={user} />
|
||||
<main className={`flex-1 min-h-0 ${isSchedulePage ? 'overflow-hidden' : 'overflow-y-auto'}`}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminLayout;
|
||||
2
frontend-temp/src/components/pc/admin/layout/index.js
Normal file
2
frontend-temp/src/components/pc/admin/layout/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as AdminLayout } from './Layout';
|
||||
export { default as AdminHeader } from './Header';
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { Clock, Tag, Link2, ExternalLink, Edit2, Trash2 } from 'lucide-react';
|
||||
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||
|
||||
/**
|
||||
* PC 관리자 일정 카드 컴포넌트
|
||||
* 관리자 일정 관리 페이지에서 사용
|
||||
* 편집/삭제 버튼 포함
|
||||
*/
|
||||
function AdminScheduleCard({
|
||||
schedule,
|
||||
onEdit,
|
||||
onDelete,
|
||||
className = '',
|
||||
}) {
|
||||
const scheduleDate = new Date(schedule.date || schedule.datetime);
|
||||
const today = new Date();
|
||||
const currentYear = today.getFullYear();
|
||||
const currentMonth = today.getMonth();
|
||||
|
||||
const scheduleYear = scheduleDate.getFullYear();
|
||||
const scheduleMonth = scheduleDate.getMonth();
|
||||
const isCurrentYear = scheduleYear === currentYear;
|
||||
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
|
||||
|
||||
const categoryInfo = getCategoryInfo(schedule);
|
||||
const timeStr = getScheduleTime(schedule);
|
||||
const displayMembers = getDisplayMembers(schedule);
|
||||
const sourceName = schedule.source?.name;
|
||||
const sourceUrl = schedule.source?.url;
|
||||
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
|
||||
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
return (
|
||||
<div className={`p-6 hover:bg-gray-50 transition-colors group ${className}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 날짜 영역 */}
|
||||
<div className="w-20 text-center flex-shrink-0">
|
||||
{!isCurrentYear && (
|
||||
<div className="text-xs text-gray-400 mb-0.5">
|
||||
{scheduleYear}.{scheduleMonth + 1}
|
||||
</div>
|
||||
)}
|
||||
{isCurrentYear && !isCurrentMonth && (
|
||||
<div className="text-xs text-gray-400 mb-0.5">
|
||||
{scheduleMonth + 1}월
|
||||
</div>
|
||||
)}
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{scheduleDate.getDate()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayNames[scheduleDate.getDay()]}요일
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 바 */}
|
||||
<div
|
||||
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
|
||||
style={{ backgroundColor: categoryInfo.color }}
|
||||
/>
|
||||
|
||||
{/* 스케줄 내용 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
|
||||
{timeStr && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{timeStr}
|
||||
</span>
|
||||
)}
|
||||
{categoryInfo.name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={14} />
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{sourceName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 size={14} />
|
||||
{sourceName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{displayMembers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{displayMembers.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 (생일 일정은 수정/삭제 불가) */}
|
||||
{!isBirthday && (
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{sourceUrl && (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</a>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(schedule)}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(schedule)}
|
||||
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminScheduleCard;
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* 카테고리 추가/수정 모달 컴포넌트
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { COLOR_OPTIONS } from '@/utils/color';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - 모달 열림 여부
|
||||
* @param {Function} props.onClose - 모달 닫기 핸들러
|
||||
* @param {Object|null} props.editingCategory - 수정 중인 카테고리 (null이면 추가 모드)
|
||||
* @param {Object} props.formData - 폼 데이터 { name, color }
|
||||
* @param {Function} props.setFormData - 폼 데이터 업데이트 함수
|
||||
* @param {boolean} props.colorPickerOpen - 컬러 피커 열림 여부
|
||||
* @param {Function} props.setColorPickerOpen - 컬러 피커 상태 변경 함수
|
||||
* @param {Function} props.onSave - 저장 핸들러
|
||||
*/
|
||||
const CategoryFormModal = memo(function CategoryFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
editingCategory,
|
||||
formData,
|
||||
setFormData,
|
||||
colorPickerOpen,
|
||||
setColorPickerOpen,
|
||||
onSave,
|
||||
}) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl p-6 w-full mx-4 shadow-xl"
|
||||
style={{ maxWidth: '452px', minWidth: '452px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6">
|
||||
{editingCategory ? '카테고리 수정' : '카테고리 추가'}
|
||||
</h3>
|
||||
|
||||
{/* 카테고리 이름 */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">카테고리 이름 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="예: 방송, 이벤트"
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 색상 선택 */}
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">색상 선택 *</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{COLOR_OPTIONS.map((color) => (
|
||||
<button
|
||||
key={color.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, color: color.id })}
|
||||
className={`w-10 h-10 rounded-full ${color.bg} transition-all ${
|
||||
formData.color === color.id
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
{/* 커스텀 색상 - 무지개 그라디언트 버튼 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setColorPickerOpen(!colorPickerOpen);
|
||||
}}
|
||||
className={`w-10 h-10 rounded-full transition-all ${
|
||||
formData.color?.startsWith('#')
|
||||
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
style={{
|
||||
background: formData.color?.startsWith('#')
|
||||
? formData.color
|
||||
: 'conic-gradient(from 0deg, #ff0000, #ff8000, #ffff00, #00ff00, #00ffff, #0000ff, #8000ff, #ff0080, #ff0000)',
|
||||
}}
|
||||
title="커스텀 색상"
|
||||
/>
|
||||
{/* 색상 선택 팝업 */}
|
||||
<AnimatePresence>
|
||||
{colorPickerOpen && (
|
||||
<>
|
||||
{/* 바깥 영역 클릭시 컬러피커만 닫기 */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setColorPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||
className="absolute top-12 left-0 z-50 p-4 bg-white rounded-2xl shadow-xl border border-gray-100"
|
||||
style={{ width: '240px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<HexColorPicker
|
||||
color={formData.color?.startsWith('#') ? formData.color : '#6b7280'}
|
||||
onChange={(color) => setFormData({ ...formData, color })}
|
||||
style={{ width: '100%', height: '180px' }}
|
||||
/>
|
||||
<div className="mt-4 flex items-center">
|
||||
<span className="px-3 py-2 text-sm bg-gray-100 border border-r-0 border-gray-200 rounded-l-lg text-gray-500">
|
||||
#
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color?.startsWith('#') ? formData.color.slice(1) : ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').slice(0, 6);
|
||||
if (val) {
|
||||
setFormData({ ...formData, color: '#' + val });
|
||||
}
|
||||
}}
|
||||
placeholder="FFFFFF"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormData({ ...formData, color: 'blue' });
|
||||
setColorPickerOpen(false);
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColorPickerOpen(false)}
|
||||
className="px-3 py-1.5 text-sm bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
{editingCategory ? '수정' : '추가'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default CategoryFormModal;
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 카테고리 선택 컴포넌트
|
||||
*/
|
||||
function CategorySelector({ categories, selectedId, onChange }) {
|
||||
// 색상 스타일 (기본 색상 또는 커스텀 HEX)
|
||||
const getColorStyle = (color) => {
|
||||
const colorMap = {
|
||||
blue: "bg-blue-500",
|
||||
green: "bg-green-500",
|
||||
purple: "bg-purple-500",
|
||||
red: "bg-red-500",
|
||||
pink: "bg-pink-500",
|
||||
yellow: "bg-yellow-500",
|
||||
orange: "bg-orange-500",
|
||||
gray: "bg-gray-500",
|
||||
cyan: "bg-cyan-500",
|
||||
indigo: "bg-indigo-500",
|
||||
};
|
||||
|
||||
if (!color) return { className: "bg-gray-500" };
|
||||
if (color.startsWith("#")) {
|
||||
return { style: { backgroundColor: color } };
|
||||
}
|
||||
return { className: colorMap[color] || "bg-gray-500" };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-900">카테고리 선택</h2>
|
||||
<Link
|
||||
to="/admin/schedule/categories"
|
||||
className="flex items-center gap-1 text-xs text-gray-400 hover:text-primary transition-colors"
|
||||
>
|
||||
<Settings size={12} />
|
||||
카테고리 관리
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{categories.map((category) => {
|
||||
const colorStyle = getColorStyle(category.color);
|
||||
const isSelected = selectedId === category.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => onChange(category.id)}
|
||||
className={`flex items-center justify-center gap-2 px-4 py-4 rounded-xl border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3 h-3 rounded-full ${colorStyle.className || ""}`}
|
||||
style={colorStyle.style}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isSelected ? "text-primary" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{category.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CategorySelector;
|
||||
141
frontend-temp/src/components/pc/admin/schedule/ImageUploader.jsx
Normal file
141
frontend-temp/src/components/pc/admin/schedule/ImageUploader.jsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* 이미지 업로드 컴포넌트
|
||||
* - 다중 이미지 업로드 및 드래그 앤 드롭 정렬
|
||||
*/
|
||||
import { useState, memo } from 'react';
|
||||
import { Image, Plus, X } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Array} props.previews - 이미지 미리보기 URL 배열
|
||||
* @param {Function} props.onUpload - 파일 업로드 핸들러 (files)
|
||||
* @param {Function} props.onDelete - 이미지 삭제 핸들러 (index)
|
||||
* @param {Function} props.onReorder - 이미지 순서 변경 핸들러 (fromIndex, toIndex)
|
||||
* @param {Function} props.onOpenLightbox - 라이트박스 열기 핸들러 (index)
|
||||
*/
|
||||
const ImageUploader = memo(function ImageUploader({
|
||||
previews,
|
||||
onUpload,
|
||||
onDelete,
|
||||
onReorder,
|
||||
onOpenLightbox,
|
||||
}) {
|
||||
const [draggedIndex, setDraggedIndex] = useState(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState(null);
|
||||
|
||||
// 파일 선택
|
||||
const handleFileChange = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length > 0) {
|
||||
onUpload(files);
|
||||
}
|
||||
// input 초기화 (같은 파일 다시 선택 가능하도록)
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// 드래그 시작
|
||||
const handleDragStart = (e, index) => {
|
||||
setDraggedIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', index);
|
||||
};
|
||||
|
||||
// 드래그 오버
|
||||
const handleDragOver = (e, index) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (dragOverIndex !== index) {
|
||||
setDragOverIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 종료
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
// 드롭 - 이미지 순서 변경
|
||||
const handleDrop = (e, dropIndex) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === dropIndex) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
onReorder(draggedIndex, dropIndex);
|
||||
handleDragEnd();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm p-8">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Image size={20} className="text-primary" />
|
||||
<h2 className="text-lg font-bold text-gray-900">일정 이미지</h2>
|
||||
<span className="text-sm text-gray-400 ml-2">여러 장 업로드 가능</span>
|
||||
</div>
|
||||
|
||||
{/* 이미지 그리드 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{/* 이미지 추가 버튼 - 항상 첫번째 */}
|
||||
<label className="aspect-square border-2 border-dashed border-gray-200 rounded-xl cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors flex flex-col items-center justify-center">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Plus size={24} className="text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500">이미지 추가</p>
|
||||
</label>
|
||||
|
||||
{/* 이미지 목록 - 드래그 앤 드롭 가능 */}
|
||||
{previews.map((preview, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`relative aspect-square rounded-xl overflow-hidden group cursor-grab active:cursor-grabbing transition-all duration-200 ${
|
||||
draggedIndex === index ? 'opacity-50 scale-95' : ''
|
||||
} ${
|
||||
dragOverIndex === index && draggedIndex !== index
|
||||
? 'ring-2 ring-primary ring-offset-2'
|
||||
: ''
|
||||
}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
>
|
||||
{/* 이미지 클릭시 라이트박스 열기 */}
|
||||
<img
|
||||
src={preview}
|
||||
alt={`업로드 ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
onClick={() => onOpenLightbox(index)}
|
||||
draggable={false}
|
||||
/>
|
||||
{/* 호버시 어두운 오버레이 */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors pointer-events-none" />
|
||||
{/* 순서 표시 */}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 bg-black/50 rounded-full text-white text-xs font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(index);
|
||||
}}
|
||||
className="absolute top-2 right-2 w-7 h-7 bg-black/50 hover:bg-red-500 rounded-full flex items-center justify-center transition-all shadow-lg"
|
||||
>
|
||||
<X size={14} className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ImageUploader;
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* 장소 검색 다이얼로그 컴포넌트
|
||||
* - 카카오 장소 검색 API를 사용
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Search, MapPin } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - 다이얼로그 열림 여부
|
||||
* @param {Function} props.onClose - 닫기 핸들러
|
||||
* @param {Function} props.onSelect - 장소 선택 핸들러 (place 객체 전달)
|
||||
*/
|
||||
function LocationSearchDialog({ isOpen, onClose, onSelect }) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
// 다이얼로그 닫기 시 상태 초기화
|
||||
const handleClose = () => {
|
||||
setSearchQuery('');
|
||||
setResults([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 카카오 장소 검색 API 호출
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const response = await fetch(`/api/admin/kakao/places?query=${encodeURIComponent(searchQuery)}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setResults(data.documents || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('장소 검색 오류:', error);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 장소 선택
|
||||
const handleSelectPlace = (place) => {
|
||||
onSelect({
|
||||
name: place.place_name,
|
||||
address: place.road_address_name || place.address_name,
|
||||
lat: parseFloat(place.y),
|
||||
lng: parseFloat(place.x),
|
||||
});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl p-6 max-w-lg w-full mx-4 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900">장소 검색</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
placeholder="장소명을 입력하세요"
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearch}
|
||||
disabled={searching}
|
||||
className="px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{searching ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
}}
|
||||
>
|
||||
<Search size={18} />
|
||||
</motion.div>
|
||||
) : (
|
||||
'검색'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 결과 */}
|
||||
<div className="max-h-80 overflow-y-auto pr-2">
|
||||
{results.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{results.map((place, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => handleSelectPlace(place)}
|
||||
className="w-full p-3 text-left hover:bg-gray-50 rounded-xl flex items-start gap-3 border border-gray-100"
|
||||
>
|
||||
<MapPin size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900">{place.place_name}</p>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{place.road_address_name || place.address_name}
|
||||
</p>
|
||||
{place.category_name && (
|
||||
<p className="text-xs text-gray-400 mt-1">{place.category_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : searchQuery && !searching ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<MapPin size={32} className="mx-auto mb-2 text-gray-300" />
|
||||
<p>검색어를 입력하고 검색 버튼을 눌러주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<MapPin size={32} className="mx-auto mb-2 text-gray-300" />
|
||||
<p>장소명을 입력하고 검색해주세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocationSearchDialog;
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* 멤버 선택 컴포넌트
|
||||
* - 일정 폼에서 참여 멤버 선택용
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { Users, Check } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Array} props.members - 전체 멤버 목록
|
||||
* @param {Array} props.selectedIds - 선택된 멤버 ID 배열
|
||||
* @param {Function} props.onToggle - 멤버 토글 핸들러
|
||||
* @param {Function} props.onToggleAll - 전체 선택/해제 핸들러
|
||||
*/
|
||||
const MemberSelector = memo(function MemberSelector({
|
||||
members,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
onToggleAll,
|
||||
}) {
|
||||
const isAllSelected = selectedIds.length === members.length;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={20} className="text-primary" />
|
||||
<h2 className="text-lg font-bold text-gray-900">참여 멤버</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAll}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{isAllSelected ? '전체 해제' : '전체 선택'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{members.map((member) => {
|
||||
const isSelected = selectedIds.includes(member.id);
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(member.id)}
|
||||
className={`relative rounded-xl overflow-hidden transition-all ${
|
||||
isSelected
|
||||
? 'ring-2 ring-primary ring-offset-2'
|
||||
: 'hover:opacity-80'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-[3/4] bg-gray-100">
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-200">
|
||||
<Users size={24} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3">
|
||||
<p className="text-white text-sm font-medium">{member.name}</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
|
||||
<Check size={14} className="text-white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default MemberSelector;
|
||||
172
frontend-temp/src/components/pc/admin/schedule/ScheduleItem.jsx
Normal file
172
frontend-temp/src/components/pc/admin/schedule/ScheduleItem.jsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* 일정 아이템 컴포넌트
|
||||
* - 일정 목록에서 사용되는 개별 아이템
|
||||
* - 일반 모드와 검색 모드에서 공통 사용
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Edit2, Trash2, ExternalLink, Clock, Tag, Link2 } from 'lucide-react';
|
||||
import { decodeHtmlEntities } from '@/utils';
|
||||
import {
|
||||
getMemberList,
|
||||
getScheduleDate,
|
||||
getScheduleTime,
|
||||
getCategoryInfo,
|
||||
} from '@/utils/schedule';
|
||||
|
||||
/**
|
||||
* 카테고리별 수정 경로 반환
|
||||
*/
|
||||
export const getEditPath = (scheduleId, categoryName) => {
|
||||
switch (categoryName) {
|
||||
case '유튜브':
|
||||
return `/admin/schedule/${scheduleId}/edit/youtube`;
|
||||
case 'X':
|
||||
return `/admin/schedule/${scheduleId}/edit/x`;
|
||||
default:
|
||||
return `/admin/schedule/${scheduleId}/edit`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 일정 아이템 컴포넌트 - React.memo로 불필요한 리렌더링 방지
|
||||
* @param {Object} props
|
||||
* @param {Object} props.schedule - 일정 데이터
|
||||
* @param {number} props.index - 목록 인덱스 (애니메이션 지연용)
|
||||
* @param {string} props.selectedDate - 선택된 날짜
|
||||
* @param {Function} props.getColorStyle - 색상 스타일 함수
|
||||
* @param {Function} props.navigate - 네비게이션 함수
|
||||
* @param {Function} props.openDeleteDialog - 삭제 다이얼로그 열기 함수
|
||||
* @param {boolean} props.showYear - 연도 표시 여부 (검색 모드용)
|
||||
* @param {boolean} props.animated - 애니메이션 적용 여부 (기본: true)
|
||||
* @param {string} props.className - 추가 클래스명
|
||||
*/
|
||||
const ScheduleItem = memo(function ScheduleItem({
|
||||
schedule,
|
||||
index = 0,
|
||||
selectedDate,
|
||||
getColorStyle,
|
||||
navigate,
|
||||
openDeleteDialog,
|
||||
showYear = false,
|
||||
animated = true,
|
||||
className = '',
|
||||
}) {
|
||||
const scheduleDate = new Date(getScheduleDate(schedule));
|
||||
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
|
||||
const categoryInfo = getCategoryInfo(schedule);
|
||||
const categoryColor =
|
||||
getColorStyle(categoryInfo.color)?.style?.backgroundColor || categoryInfo.color || '#6b7280';
|
||||
const memberList = getMemberList(schedule);
|
||||
const timeStr = getScheduleTime(schedule);
|
||||
|
||||
const content = (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-20 text-center flex-shrink-0">
|
||||
{showYear && (
|
||||
<div className="text-xs text-gray-400 mb-0.5">
|
||||
{scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-2xl font-bold text-gray-900">{scheduleDate.getDate()}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}요일
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900">{decodeHtmlEntities(schedule.title)}</h3>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
|
||||
{timeStr && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{timeStr}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={14} />
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
{schedule.source?.name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 size={14} />
|
||||
{schedule.source?.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{memberList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{memberList.length >= 5 ? (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
프로미스나인
|
||||
</span>
|
||||
) : (
|
||||
memberList.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"
|
||||
>
|
||||
{name.trim()}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 생일 일정은 수정/삭제 불가 */}
|
||||
{!isBirthday && (
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{schedule.source?.url && (
|
||||
<a
|
||||
href={schedule.source?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name))}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDeleteDialog(schedule)}
|
||||
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const baseClassName = `${showYear ? 'p-5' : 'p-6'} hover:bg-gray-50 transition-colors group ${className}`;
|
||||
|
||||
if (animated) {
|
||||
return (
|
||||
<motion.div
|
||||
key={`${schedule.id}-${selectedDate || 'all'}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: Math.min(index, 10) * 0.03 }}
|
||||
className={baseClassName}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClassName}>{content}</div>;
|
||||
});
|
||||
|
||||
export default ScheduleItem;
|
||||
168
frontend-temp/src/components/pc/admin/schedule/WordItem.jsx
Normal file
168
frontend-temp/src/components/pc/admin/schedule/WordItem.jsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* 사전 단어 항목 컴포넌트
|
||||
* - 사전 관리 페이지의 단어 테이블 행
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Trash2, ChevronDown } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 품사 태그 옵션
|
||||
*/
|
||||
export const POS_TAGS = [
|
||||
{
|
||||
value: 'NNP',
|
||||
label: '고유명사 (NNP)',
|
||||
description: '사람, 그룹, 프로그램 이름 등',
|
||||
examples: '프로미스나인, 송하영, 뮤직뱅크',
|
||||
},
|
||||
{
|
||||
value: 'NNG',
|
||||
label: '일반명사 (NNG)',
|
||||
description: '일반적인 명사',
|
||||
examples: '직캠, 팬미팅, 콘서트',
|
||||
},
|
||||
{
|
||||
value: 'SL',
|
||||
label: '외국어 (SL)',
|
||||
description: '영어 등 외국어 단어',
|
||||
examples: 'fromis_9, YouTube, fromm',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 단어 항목 컴포넌트
|
||||
* @param {Object} props
|
||||
* @param {string} props.id - 단어 고유 ID
|
||||
* @param {string} props.word - 단어
|
||||
* @param {string} props.pos - 품사 태그
|
||||
* @param {number} props.index - 목록 인덱스
|
||||
* @param {Function} props.onUpdate - 수정 핸들러 (id, word, pos)
|
||||
* @param {Function} props.onDelete - 삭제 핸들러 ()
|
||||
*/
|
||||
function WordItem({ id, word, pos, index, onUpdate, onDelete }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editWord, setEditWord] = useState(word);
|
||||
const [editPos, setEditPos] = useState(pos);
|
||||
const [showPosDropdown, setShowPosDropdown] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// 외부 클릭 시 드롭다운 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setShowPosDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showPosDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showPosDropdown]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (editWord.trim() && (editWord.trim() !== word || editPos !== pos)) {
|
||||
onUpdate(id, editWord.trim(), editPos);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditWord(word);
|
||||
setEditPos(pos);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="group hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-gray-400 w-16">{index + 1}</td>
|
||||
<td className="px-4 py-3">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editWord}
|
||||
onChange={(e) => setEditWord(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
autoFocus
|
||||
className="w-full px-3 py-1.5 border border-primary rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="cursor-pointer hover:text-primary transition-colors font-medium"
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 w-48">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowPosDropdown(!showPosDropdown)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors w-full justify-between"
|
||||
>
|
||||
<span>
|
||||
{POS_TAGS.find((t) => t.value === (isEditing ? editPos : pos))?.label.split(' ')[0] || pos}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform ${showPosDropdown ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showPosDropdown && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||
>
|
||||
{POS_TAGS.map((tag) => (
|
||||
<button
|
||||
key={tag.value}
|
||||
onClick={() => {
|
||||
if (isEditing) {
|
||||
setEditPos(tag.value);
|
||||
} else {
|
||||
onUpdate(id, word, tag.value);
|
||||
}
|
||||
setShowPosDropdown(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||
(isEditing ? editPos : pos) === tag.value ? 'bg-primary/5 text-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">{tag.label}</div>
|
||||
<div className="text-xs text-gray-400">{tag.description}</div>
|
||||
<div className="text-xs text-gray-300 mt-0.5">예: {tag.examples}</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-20">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default WordItem;
|
||||
8
frontend-temp/src/components/pc/admin/schedule/index.js
Normal file
8
frontend-temp/src/components/pc/admin/schedule/index.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export { default as AdminScheduleCard } from './AdminScheduleCard';
|
||||
export { default as CategorySelector } from './CategorySelector';
|
||||
export { default as ScheduleItem, getEditPath } from './ScheduleItem';
|
||||
export { default as LocationSearchDialog } from './LocationSearchDialog';
|
||||
export { default as MemberSelector } from './MemberSelector';
|
||||
export { default as ImageUploader } from './ImageUploader';
|
||||
export { default as WordItem, POS_TAGS } from './WordItem';
|
||||
export { default as CategoryFormModal } from './CategoryFormModal';
|
||||
5
frontend-temp/src/components/pc/public/index.js
Normal file
5
frontend-temp/src/components/pc/public/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// 레이아웃
|
||||
export * from './layout';
|
||||
|
||||
// 일정 컴포넌트
|
||||
export * from './schedule';
|
||||
22
frontend-temp/src/components/pc/public/layout/Footer.jsx
Normal file
22
frontend-temp/src/components/pc/public/layout/Footer.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* PC 푸터 컴포넌트
|
||||
*/
|
||||
function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-900 text-white py-8">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
{/* 저작권 */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p>
|
||||
© {currentYear} fromis_9 Fan Site. This is an unofficial
|
||||
fan-made website.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
84
frontend-temp/src/components/pc/public/layout/Header.jsx
Normal file
84
frontend-temp/src/components/pc/public/layout/Header.jsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { NavLink } from 'react-router-dom';
|
||||
import { Instagram, Youtube } from 'lucide-react';
|
||||
import { SOCIAL_LINKS } from '@/constants';
|
||||
|
||||
/**
|
||||
* X (Twitter) 아이콘 컴포넌트
|
||||
*/
|
||||
const XIcon = ({ size = 20 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* PC 헤더 컴포넌트
|
||||
*/
|
||||
function Header() {
|
||||
const navItems = [
|
||||
{ path: '/', label: '홈' },
|
||||
{ path: '/members', label: '멤버' },
|
||||
{ path: '/album', label: '앨범' },
|
||||
{ path: '/schedule', label: '일정' },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm sticky top-0 z-50">
|
||||
<div className="px-24">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* 로고 */}
|
||||
<NavLink to="/" className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-primary">fromis_9</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 네비게이션 */}
|
||||
<nav className="flex items-center gap-8">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`text-sm font-medium transition-colors hover:text-primary ${
|
||||
isActive ? 'text-primary' : 'text-gray-600'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* SNS 링크 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href={SOCIAL_LINKS.youtube}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<Youtube size={20} />
|
||||
</a>
|
||||
<a
|
||||
href={SOCIAL_LINKS.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 hover:text-pink-600 transition-colors"
|
||||
>
|
||||
<Instagram size={20} />
|
||||
</a>
|
||||
<a
|
||||
href={SOCIAL_LINKS.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 hover:text-black transition-colors"
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
37
frontend-temp/src/components/pc/public/layout/Layout.jsx
Normal file
37
frontend-temp/src/components/pc/public/layout/Layout.jsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import '@/pc.css';
|
||||
|
||||
/**
|
||||
* PC 레이아웃 컴포넌트
|
||||
*/
|
||||
function Layout({ children }) {
|
||||
const location = useLocation();
|
||||
|
||||
// Footer 숨김 페이지 (화면 고정 레이아웃)
|
||||
const hideFooterPages = ['/schedule', '/members', '/album', '/birthday'];
|
||||
const hideFooter = hideFooterPages.some(
|
||||
(path) =>
|
||||
location.pathname === path || location.pathname.startsWith(path + '/')
|
||||
);
|
||||
|
||||
// 일정 페이지에서는 스크롤바도 숨김 (내부에서 자체 스크롤 처리)
|
||||
const isSchedulePage = location.pathname === '/schedule';
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden flex flex-col">
|
||||
<Header />
|
||||
<main
|
||||
className={`flex-1 min-h-0 flex flex-col ${
|
||||
isSchedulePage ? 'overflow-hidden' : 'overflow-y-auto'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 flex flex-col">{children}</div>
|
||||
{!hideFooter && <Footer />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
3
frontend-temp/src/components/pc/public/layout/index.js
Normal file
3
frontend-temp/src/components/pc/public/layout/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as Layout } from './Layout';
|
||||
export { default as Header } from './Header';
|
||||
export { default as Footer } from './Footer';
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { memo } from 'react';
|
||||
import { dayjs } from '@/utils';
|
||||
|
||||
/**
|
||||
* PC용 생일 카드 컴포넌트
|
||||
*/
|
||||
const BirthdayCard = memo(function BirthdayCard({ schedule, showYear = false, onClick }) {
|
||||
const scheduleDate = dayjs(schedule.date);
|
||||
const formatted = {
|
||||
year: scheduleDate.year(),
|
||||
month: scheduleDate.month() + 1,
|
||||
day: scheduleDate.date(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="relative overflow-hidden bg-gradient-to-r from-pink-400 via-purple-400 to-indigo-400 rounded-2xl shadow-lg hover:shadow-xl transition-shadow cursor-pointer"
|
||||
>
|
||||
{/* 배경 장식 */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-4 -right-4 w-24 h-24 bg-white/10 rounded-full" />
|
||||
<div className="absolute -bottom-6 -left-6 w-32 h-32 bg-white/10 rounded-full" />
|
||||
<div className="absolute top-1/2 right-1/4 w-16 h-16 bg-white/5 rounded-full" />
|
||||
<div className="absolute bottom-4 left-12 text-xl animate-pulse">🎉</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center p-4 gap-4">
|
||||
{/* 멤버 사진 */}
|
||||
{schedule.member_image && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-20 h-20 rounded-full border-4 border-white/50 shadow-lg overflow-hidden bg-white">
|
||||
<img
|
||||
src={schedule.member_image}
|
||||
alt={schedule.member_names}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 text-white flex items-center gap-3">
|
||||
<span className="text-4xl">🎂</span>
|
||||
<h3 className="font-bold text-2xl tracking-wide">{schedule.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* 날짜 뱃지 */}
|
||||
<div className="flex-shrink-0 bg-white/20 backdrop-blur-sm rounded-xl px-4 py-2 text-center">
|
||||
{showYear && (
|
||||
<div className="text-white/70 text-xs font-medium">{formatted.year}</div>
|
||||
)}
|
||||
<div className="text-white/70 text-xs font-medium">{formatted.month}월</div>
|
||||
<div className="text-white text-2xl font-bold">{formatted.day}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default BirthdayCard;
|
||||
333
frontend-temp/src/components/pc/public/schedule/Calendar.jsx
Normal file
333
frontend-temp/src/components/pc/public/schedule/Calendar.jsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { getTodayKST, dayjs } from '@/utils';
|
||||
import { MIN_YEAR, WEEKDAYS, MONTH_NAMES } from '@/constants';
|
||||
|
||||
const MONTHS = MONTH_NAMES;
|
||||
|
||||
/**
|
||||
* PC용 달력 컴포넌트
|
||||
* @param {Date} currentDate - 현재 표시 중인 년/월
|
||||
* @param {function} onDateChange - 년/월 변경 핸들러
|
||||
* @param {string} selectedDate - 선택된 날짜 (YYYY-MM-DD)
|
||||
* @param {function} onSelectDate - 날짜 선택 핸들러
|
||||
* @param {Array} schedules - 일정 목록 (점 표시용)
|
||||
* @param {function} getCategoryColor - 카테고리 색상 가져오기
|
||||
* @param {boolean} disabled - 비활성화 여부
|
||||
*/
|
||||
function Calendar({
|
||||
currentDate,
|
||||
onDateChange,
|
||||
selectedDate,
|
||||
onSelectDate,
|
||||
schedules = [],
|
||||
getCategoryColor,
|
||||
disabled = false,
|
||||
}) {
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false);
|
||||
const [slideDirection, setSlideDirection] = useState(0);
|
||||
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
|
||||
const pickerRef = useRef(null);
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
// 외부 클릭 시 팝업 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(event.target)) {
|
||||
setShowYearMonthPicker(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 달력 계산
|
||||
const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
|
||||
const getFirstDayOfMonth = (y, m) => new Date(y, m, 1).getDay();
|
||||
|
||||
const daysInMonth = getDaysInMonth(year, month);
|
||||
const firstDay = getFirstDayOfMonth(year, month);
|
||||
|
||||
// 일정 날짜별 맵 (O(1) 조회용)
|
||||
const scheduleDateMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
schedules.forEach((s) => {
|
||||
const dateStr = s.date ? s.date.split('T')[0] : '';
|
||||
if (!map.has(dateStr)) {
|
||||
map.set(dateStr, []);
|
||||
}
|
||||
map.get(dateStr).push(s);
|
||||
});
|
||||
return map;
|
||||
}, [schedules]);
|
||||
|
||||
// 2017년 1월 이전으로 이동 불가
|
||||
const canGoPrevMonth = !(year === MIN_YEAR && month === 0);
|
||||
|
||||
const prevMonth = () => {
|
||||
if (!canGoPrevMonth) return;
|
||||
setSlideDirection(-1);
|
||||
const newDate = new Date(year, month - 1, 1);
|
||||
onDateChange(newDate);
|
||||
// 이번달이면 오늘, 다른 달이면 1일 선택
|
||||
const today = new Date();
|
||||
if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) {
|
||||
onSelectDate(getTodayKST());
|
||||
} else {
|
||||
onSelectDate(`${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`);
|
||||
}
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
setSlideDirection(1);
|
||||
const newDate = new Date(year, month + 1, 1);
|
||||
onDateChange(newDate);
|
||||
const today = new Date();
|
||||
if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) {
|
||||
onSelectDate(getTodayKST());
|
||||
} else {
|
||||
onSelectDate(`${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`);
|
||||
}
|
||||
};
|
||||
|
||||
const selectYear = (newYear) => {
|
||||
onDateChange(new Date(newYear, month, 1));
|
||||
};
|
||||
|
||||
const selectMonth = (newMonth) => {
|
||||
const newDate = new Date(year, newMonth, 1);
|
||||
onDateChange(newDate);
|
||||
const today = new Date();
|
||||
if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) {
|
||||
onSelectDate(getTodayKST());
|
||||
} else {
|
||||
onSelectDate(`${year}-${String(newMonth + 1).padStart(2, '0')}-01`);
|
||||
}
|
||||
setShowYearMonthPicker(false);
|
||||
};
|
||||
|
||||
const selectDate = (day) => {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
onSelectDate(dateStr);
|
||||
};
|
||||
|
||||
// 연도 범위
|
||||
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
|
||||
const canGoPrevYearRange = yearRangeStart > MIN_YEAR;
|
||||
const prevYearRange = () => canGoPrevYearRange && setYearRangeStart((prev) => Math.max(MIN_YEAR, prev - 12));
|
||||
const nextYearRange = () => setYearRangeStart((prev) => prev + 12);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const isCurrentYear = (y) => y === currentYear;
|
||||
const isCurrentMonth = (m) => {
|
||||
const now = new Date();
|
||||
return year === now.getFullYear() && m === now.getMonth();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: disabled ? 0.4 : 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={disabled ? 'pointer-events-none' : ''}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-sm pt-8 px-8 pb-6 relative" ref={pickerRef}>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<button
|
||||
aria-label="이전 달"
|
||||
onClick={prevMonth}
|
||||
disabled={!canGoPrevMonth}
|
||||
className={`p-2 rounded-full transition-colors ${canGoPrevMonth ? 'hover:bg-gray-100' : 'opacity-30'}`}
|
||||
>
|
||||
<ChevronLeft size={24} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="년/월 선택"
|
||||
aria-expanded={showYearMonthPicker}
|
||||
onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}
|
||||
className="flex items-center gap-1 text-xl font-bold hover:text-primary transition-colors"
|
||||
>
|
||||
<span>{year}년 {month + 1}월</span>
|
||||
<ChevronDown size={20} aria-hidden="true" className={`transition-transform ${showYearMonthPicker ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<button aria-label="다음 달" onClick={nextMonth} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<ChevronRight size={24} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 년/월 선택 팝업 */}
|
||||
<AnimatePresence>
|
||||
{showYearMonthPicker && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="absolute top-20 left-8 right-8 mx-auto w-80 bg-white rounded-xl shadow-lg border border-gray-200 p-4 z-10"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
aria-label="이전 연도 범위"
|
||||
onClick={prevYearRange}
|
||||
disabled={!canGoPrevYearRange}
|
||||
className={`p-1.5 rounded-lg transition-colors ${canGoPrevYearRange ? 'hover:bg-gray-100' : 'opacity-30'}`}
|
||||
>
|
||||
<ChevronLeft size={20} aria-hidden="true" className="text-gray-600" />
|
||||
</button>
|
||||
<span className="font-medium text-gray-900">
|
||||
{yearRange[0]} - {yearRange[yearRange.length - 1]}
|
||||
</span>
|
||||
<button aria-label="다음 연도 범위" onClick={nextYearRange} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<ChevronRight size={20} aria-hidden="true" className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 년도 선택 */}
|
||||
<div className="text-center text-sm text-gray-500 mb-3">년도</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
{yearRange.map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
aria-label={`${y}년 선택`}
|
||||
aria-pressed={year === y}
|
||||
onClick={() => selectYear(y)}
|
||||
className={`py-2 text-sm rounded-lg transition-colors ${
|
||||
year === y
|
||||
? 'bg-primary text-white'
|
||||
: isCurrentYear(y) && year !== y
|
||||
? 'text-primary font-medium hover:bg-primary/10'
|
||||
: 'hover:bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 월 선택 */}
|
||||
<div className="text-center text-sm text-gray-500 mb-3">월</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{MONTHS.map((m, i) => (
|
||||
<button
|
||||
key={m}
|
||||
aria-label={`${m} 선택`}
|
||||
aria-pressed={month === i}
|
||||
onClick={() => selectMonth(i)}
|
||||
className={`py-2 text-sm rounded-lg transition-colors ${
|
||||
month === i
|
||||
? 'bg-primary text-white'
|
||||
: isCurrentMonth(i) && month !== i
|
||||
? 'text-primary font-medium hover:bg-primary/10'
|
||||
: 'hover:bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 요일 헤더 + 날짜 그리드 */}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`${year}-${month}`}
|
||||
initial={{ opacity: 0, x: slideDirection * 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: slideDirection * -20 }}
|
||||
transition={{ duration: 0.08 }}
|
||||
>
|
||||
<div className="grid grid-cols-7 mb-4">
|
||||
{WEEKDAYS.map((day, i) => (
|
||||
<div
|
||||
key={day}
|
||||
className={`text-center text-sm font-medium py-2 ${
|
||||
i === 0 ? 'text-red-500' : i === 6 ? 'text-blue-500' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{/* 전달 날짜 */}
|
||||
{Array.from({ length: firstDay }).map((_, i) => {
|
||||
const prevMonthDays = getDaysInMonth(year, month - 1);
|
||||
const day = prevMonthDays - firstDay + i + 1;
|
||||
return (
|
||||
<div key={`prev-${i}`} className="aspect-square flex items-center justify-center text-gray-300 text-base">
|
||||
{day}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 현재 달 날짜 */}
|
||||
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||
const day = i + 1;
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const isSelected = selectedDate === dateStr;
|
||||
const dayOfWeek = (firstDay + i) % 7;
|
||||
const isToday = new Date().toDateString() === new Date(year, month, day).toDateString();
|
||||
const daySchedules = (scheduleDateMap.get(dateStr) || []).slice(0, 3);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
aria-label={`${month + 1}월 ${day}일${isToday ? ' (오늘)' : ''}${daySchedules.length > 0 ? `, 일정 ${daySchedules.length}개` : ''}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => selectDate(day)}
|
||||
className={`aspect-square flex flex-col items-center justify-center rounded-full text-base font-medium transition-all relative hover:bg-gray-100
|
||||
${isSelected ? 'bg-primary text-white shadow-lg hover:bg-primary' : ''}
|
||||
${isToday && !isSelected ? 'text-primary font-bold' : ''}
|
||||
${dayOfWeek === 0 && !isSelected && !isToday ? 'text-red-500' : ''}
|
||||
${dayOfWeek === 6 && !isSelected && !isToday ? 'text-blue-500' : ''}
|
||||
`}
|
||||
>
|
||||
<span>{day}</span>
|
||||
{!isSelected && daySchedules.length > 0 && (
|
||||
<span className="absolute bottom-1 flex gap-0.5">
|
||||
{daySchedules.map((schedule, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="w-1 h-1 rounded-full"
|
||||
style={{ backgroundColor: getCategoryColor?.(schedule.category_id, schedule) || '#4A7C59' }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 다음달 날짜 */}
|
||||
{(() => {
|
||||
const totalCells = firstDay + daysInMonth;
|
||||
const remainder = totalCells % 7;
|
||||
const nextDays = remainder === 0 ? 0 : 7 - remainder;
|
||||
return Array.from({ length: nextDays }).map((_, i) => (
|
||||
<div key={`next-${i}`} className="aspect-square flex items-center justify-center text-gray-300 text-base">
|
||||
{i + 1}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-100 flex items-center text-sm">
|
||||
<div className="flex items-center gap-1.5 text-gray-500">
|
||||
<span className="w-2 h-2 rounded-full bg-primary flex-shrink-0" />
|
||||
<span className="leading-none">일정 있음</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Calendar;
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* PC용 카테고리 필터 컴포넌트
|
||||
* @param {Array} categories - 카테고리 목록
|
||||
* @param {Array} selectedCategories - 선택된 카테고리 ID 목록
|
||||
* @param {function} onToggle - 카테고리 토글 핸들러
|
||||
* @param {function} onClear - 전체 선택 핸들러
|
||||
* @param {Map} categoryCounts - 카테고리별 개수 맵
|
||||
* @param {boolean} disabled - 비활성화 여부
|
||||
*/
|
||||
function CategoryFilter({
|
||||
categories,
|
||||
selectedCategories,
|
||||
onToggle,
|
||||
onClear,
|
||||
categoryCounts,
|
||||
disabled = false,
|
||||
}) {
|
||||
// 정렬된 카테고리 목록 (개수 기준, '기타'는 맨 뒤)
|
||||
const sortedCategories = useMemo(() => {
|
||||
return categories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
count: categoryCounts.get(category.id) || 0,
|
||||
}))
|
||||
.filter((category) => category.count > 0)
|
||||
.sort((a, b) => {
|
||||
if (a.name === '기타') return 1;
|
||||
if (b.name === '기타') return -1;
|
||||
return b.count - a.count;
|
||||
});
|
||||
}, [categories, categoryCounts]);
|
||||
|
||||
const totalCount = categoryCounts.get('total') || 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: disabled ? 0.4 : 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`bg-white rounded-2xl shadow-sm p-6 ${disabled ? 'pointer-events-none' : ''}`}
|
||||
>
|
||||
<h3 className="font-bold text-gray-900 mb-4">카테고리</h3>
|
||||
<div className="space-y-1">
|
||||
{/* 전체 */}
|
||||
<button
|
||||
onClick={onClear}
|
||||
className={`w-full flex items-center justify-between px-3 py-3 rounded-lg transition-colors ${
|
||||
selectedCategories.length === 0 ? 'bg-primary/10 text-primary' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-gray-400" />
|
||||
<span>전체</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">{totalCount}</span>
|
||||
</button>
|
||||
|
||||
{/* 개별 카테고리 */}
|
||||
{sortedCategories.map((category) => {
|
||||
const isSelected = selectedCategories.includes(category.id);
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => onToggle(category.id)}
|
||||
className={`w-full flex items-center justify-between px-3 py-3 rounded-lg transition-colors ${
|
||||
isSelected ? 'bg-primary/10 text-primary' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: category.color }} />
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">{category.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CategoryFilter;
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { memo } from 'react';
|
||||
import { Clock, Tag, Link2 } from 'lucide-react';
|
||||
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||
|
||||
/**
|
||||
* PC용 일정 카드 컴포넌트
|
||||
* 홈, 스케줄 페이지에서 공통으로 사용
|
||||
*/
|
||||
const ScheduleCard = memo(function ScheduleCard({ schedule, onClick, className = '' }) {
|
||||
const scheduleDate = new Date(schedule.date);
|
||||
const today = new Date();
|
||||
const currentYear = today.getFullYear();
|
||||
const currentMonth = today.getMonth();
|
||||
|
||||
const scheduleYear = scheduleDate.getFullYear();
|
||||
const scheduleMonth = scheduleDate.getMonth();
|
||||
const isCurrentYear = scheduleYear === currentYear;
|
||||
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
|
||||
|
||||
const categoryInfo = getCategoryInfo(schedule);
|
||||
const timeStr = getScheduleTime(schedule);
|
||||
const displayMembers = getDisplayMembers(schedule);
|
||||
const sourceName = schedule.source?.name;
|
||||
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer ${className}`}
|
||||
>
|
||||
{/* 날짜 영역 */}
|
||||
<div
|
||||
className="w-24 flex flex-col items-center justify-center text-white py-6"
|
||||
style={{ backgroundColor: categoryInfo.color }}
|
||||
>
|
||||
{!isCurrentYear && (
|
||||
<span className="text-xs font-medium opacity-60">
|
||||
{scheduleYear}.{scheduleMonth + 1}
|
||||
</span>
|
||||
)}
|
||||
{isCurrentYear && !isCurrentMonth && (
|
||||
<span className="text-xs font-medium opacity-60">
|
||||
{scheduleMonth + 1}월
|
||||
</span>
|
||||
)}
|
||||
<span className="text-3xl font-bold">{scheduleDate.getDate()}</span>
|
||||
<span className="text-sm font-medium opacity-80">
|
||||
{dayNames[scheduleDate.getDay()]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 스케줄 내용 */}
|
||||
<div className="flex-1 p-6 flex flex-col justify-center">
|
||||
<h3 className="font-bold text-lg mb-2">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3 text-base text-gray-500">
|
||||
{timeStr && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={16} className="opacity-60" />
|
||||
{timeStr}
|
||||
</span>
|
||||
)}
|
||||
{categoryInfo.name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={16} className="opacity-60" />
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{sourceName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 size={16} className="opacity-60" />
|
||||
{sourceName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{displayMembers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{displayMembers.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ScheduleCard;
|
||||
4
frontend-temp/src/components/pc/public/schedule/index.js
Normal file
4
frontend-temp/src/components/pc/public/schedule/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { default as Calendar } from './Calendar';
|
||||
export { default as ScheduleCard } from './ScheduleCard';
|
||||
export { default as BirthdayCard } from './BirthdayCard';
|
||||
export { default as CategoryFilter } from './CategoryFilter';
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue