Compare commits

...

21 commits

Author SHA1 Message Date
0c6d250a9d feat: 모바일 곡 상세 페이지 애니메이션 개선
- 앨범 상세 페이지와 동일한 순차 애니메이션 적용
- 툴바 제목 "곡 상세" → "앨범"으로 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:38:37 +09:00
821ff64bad refactor: 모바일 UI 개선
- 멤버 페이지: 바텀시트를 가운데 다이얼로그로 변경, 닫기 버튼 추가
- 앨범 상세: 섹션별 순차 애니메이션, 앨범 소개 다이얼로그로 변경
- 앨범 갤러리: 헤더 뒤로가기 버튼/클릭 기능 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:36:27 +09:00
89e346d2c6 fix: X 일정 상세 UI 개선
- 닉네임과 @id 사이 간격 축소 (PC/모바일)
- 모바일 툴바에서 뒤로가기 버튼 제거, 타이틀 가운데 정렬

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:15:15 +09:00
87a69c0cbd refactor: API 응답에서 datetime을 date와 time으로 분리
- datetime 필드를 date와 time 필드로 분리하여 00:00 시간도 정상 표시되도록 수정
- 백엔드: formatSchedule, Meilisearch 검색 결과, 스키마 업데이트
- 프론트엔드: datetime 파싱 로직 제거, date/time 직접 사용
- 문서: API 응답 예시 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:11:02 +09:00
5fa9c2a9d0 feat: 모바일 멤버 페이지 그리드 레이아웃으로 리디자인
- 카드 스와이프에서 2열 그리드 레이아웃으로 변경
- 현재 멤버와 전 멤버 섹션 분리
- 멤버 선택 시 드래그 가능한 바텀 시트 다이얼로그
- AnimatePresence로 열기/닫기 애니메이션 추가
- 그리드에서 image_medium, 다이얼로그에서 image_thumb 사용
- 디자인 비교용 미리보기 페이지 추가 (/members-preview)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 09:54:01 +09:00
8bc09e7c0d docs: 문서 업데이트 및 improvements.md 삭제
- improvements.md: 모든 개선 작업 완료로 삭제
- api.md:
  - 로그인 Rate Limit 정보 추가
  - 봇 API에 last_sync_duration, version 필드 추가
  - 타임스탬프 KST 형식으로 업데이트
- architecture.md: backend/utils 폴더 구조 추가
- development.md: Redis KEYS → SCAN 반영

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:06:25 +09:00
bdd2dfcd84 feat: 성능 최적화 (Phase 4)
- cache.js: Redis KEYS → SCAN으로 변경 (블로킹 방지)
- suggestions.js: 동기식 파일 I/O → 비동기 변경
  - readFileSync → readFile (fs/promises)
  - writeFileSync → writeFile (fs/promises)
- improvements.md: Phase 4 완료로 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:04:27 +09:00
85f03cb2d8 타임스탬프 KST 통일 및 Meilisearch 동기화 소요 시간 추가
- date.js: nowKST() 함수 추가
- 모든 타임스탬프를 UTC에서 KST(+09:00)로 변경
  - scheduler.js, bots.js, x/index.js, logger.js, app.js
- Meilisearch 봇에 동기화 소요 시간(ms) 추적 추가
- BotCard.jsx: 중복된 마지막 동기화 대신 소요 시간 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:00:58 +09:00
95285634e9 cron 스케줄러 한국 시간대 적용 및 봇 카드 UI 개선
- scheduler.js: cron.schedule에 timezone: 'Asia/Seoul' 옵션 추가
- bots.js: Meilisearch 봇 API에 버전 정보 추가
- BotCard.jsx: Meilisearch 봇 카드에 마지막 동기화 시간, 동기화 수, 버전 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:52:01 +09:00
8091f4ac67 feat: Meilisearch 버전 체크 기반 자동 동기화
- 4시~4시 5분간 1분 간격으로 Meilisearch 버전 체크
- watchtower 업데이트로 버전 변경 감지 시 즉시 동기화
- 동기화 오류 시 인덱스 삭제 후 재생성하여 재시도
- 기존 고정 시간(4:05) cron 방식에서 버전 감지 방식으로 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:41:05 +09:00
52a655bf76 feat: 외부 서비스 안정성 개선 (Phase 3)
- Nitter 요청에 10초 타임아웃 및 HTTP 상태 코드 검증 추가
- Meilisearch syncAllSchedules에서 불필요한 deleteAllDocuments 제거
  - addDocuments는 같은 ID면 자동 업데이트(upsert)
- 일정 삭제 시 Meilisearch 동기화 코드 정리 (동적 import 제거)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:01:35 +09:00
e852f215a3 feat: 보안 강화 및 인증 개선 (Phase 2)
- 로그인 Rate Limit 추가 (5회/분, 마지막 시도 기준 리셋)
- Multipart JSON 파싱 에러 처리 추가
- 로그아웃 시 무한 리다이렉트 버그 수정
- 인증 라우트 가드(RequireAuth) 추가로 비로그인 접근 차단
- Zustand hydration 대기로 페이지 깜빡임 해결
- admin/public 라우트 조건부 렌더링으로 경로 매칭 경고 해결

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:47:05 +09:00
4edef16310 fix: 일정 저장/삭제에 트랜잭션 적용
- youtube/index.js: saveVideo에 withTransaction 적용
- x/index.js: saveTweet, saveYoutubeFromTweet에 withTransaction 적용
- schedules/index.js: DELETE 핸들러에 withTransaction 적용
- 중간 실패 시 자동 롤백으로 데이터 무결성 보장
- docs/improvements.md 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:21:56 +09:00
d29c966ae0 docs: 문서 업데이트 및 code-review.md 삭제
- architecture.md: routes/ 폴더 구조 추가
- development.md: API 클라이언트 헬퍼 사용법 추가
- code-review.md: 모든 작업 완료로 삭제

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:40:59 +09:00
bd7fbea082 refactor: API 클라이언트 레거시 export 삭제
마이그레이션 완료로 더 이상 사용되지 않는 개별 export 제거
- get, post, put, del
- authGet, authPost, authPut, authDel

현재 방식: api.get(), authApi.post() 등

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:35:33 +09:00
a151694630 refactor: App.jsx 라우트 분리
라우트를 플랫폼/영역별로 분리하여 관리 용이성 향상
- routes/pc/public/index.jsx: PC 공개 라우트
- routes/pc/admin/index.jsx: PC 관리자 라우트
- routes/mobile/index.jsx: Mobile 라우트
- App.jsx: 194줄 → 47줄로 간소화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:34:28 +09:00
607457790d fix: 어드민 하위 페이지 스크롤 문제 수정
일정 목록 페이지(/admin/schedule)만 내부 스크롤 처리하도록 변경하여
봇 관리, 사전 관리, 일정 추가 등 하위 페이지에서 스크롤이 정상 작동하도록 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:04:01 +09:00
1c9b30b783 에러 유틸리티 함수를 모든 라우트에 적용
utils/error.js에 정의된 헬퍼 함수들(badRequest, unauthorized, notFound,
conflict, serverError)을 전체 라우트 파일에 적용하여 에러 응답 처리 일관성 확보

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:24:42 +09:00
897bdc471c feat: Meilisearch 동기화 봇 추가
- 매일 새벽 4시 5분 자동 재색인 (Watchtower 업데이트 후)
- 봇 관리 페이지에서 수동 동기화 및 시작/정지 가능
- bots.js에 meilisearch 타입 봇 설정 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:14:17 +09:00
b8137935c2 refactor: 보안 강화 및 앨범 삭제 로직 개선
- JWT_SECRET 환경변수 필수화 (기본값 제거)
- 앨범 삭제 시 S3 파일(사진, 티저, 비디오) 함께 삭제
- 앨범 삭제 시 관련 DB 테이블 정리 (album_photo_members, album_photos, album_teasers)
- Meilisearch latest 태그로 변경 (v1.6 → latest)
- 코드 리뷰 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:00:17 +09:00
980ae3fe1d refactor: frontend-temp를 frontend로 대체 및 문서 업데이트
- frontend 폴더를 새로 리팩토링된 frontend-temp로 교체
- docs/architecture.md: 현재 프로젝트 구조 반영
- docs/development.md: API 클라이언트 구조 업데이트
- docs/frontend-improvement.md 삭제 (완료된 개선 계획)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:29:30 +09:00
272 changed files with 5052 additions and 27390 deletions

View file

@ -12,6 +12,7 @@
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0", "@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0", "@fastify/multipart": "^9.3.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^8.0.0", "@fastify/static": "^8.0.0",
"@fastify/swagger": "^9.0.0", "@fastify/swagger": "^9.0.0",
"@scalar/fastify-api-reference": "^1.25.0", "@scalar/fastify-api-reference": "^1.25.0",
@ -1151,6 +1152,27 @@
"ipaddr.js": "^2.1.0" "ipaddr.js": "^2.1.0"
} }
}, },
"node_modules/@fastify/rate-limit": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
"integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"fastify-plugin": "^5.0.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@fastify/send": { "node_modules/@fastify/send": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",

View file

@ -11,6 +11,7 @@
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0", "@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0", "@fastify/multipart": "^9.3.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^8.0.0", "@fastify/static": "^8.0.0",
"@fastify/swagger": "^9.0.0", "@fastify/swagger": "^9.0.0",
"@scalar/fastify-api-reference": "^1.25.0", "@scalar/fastify-api-reference": "^1.25.0",

View file

@ -6,8 +6,10 @@ import fastifyCors from '@fastify/cors';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import fastifySwagger from '@fastify/swagger'; import fastifySwagger from '@fastify/swagger';
import multipart from '@fastify/multipart'; import multipart from '@fastify/multipart';
import rateLimit from '@fastify/rate-limit';
import config from './config/index.js'; import config from './config/index.js';
import * as schemas from './schemas/index.js'; import * as schemas from './schemas/index.js';
import { nowKST } from './utils/date.js';
// 플러그인 // 플러그인
import dbPlugin from './plugins/db.js'; import dbPlugin from './plugins/db.js';
@ -50,6 +52,11 @@ export async function buildApp(opts = {}) {
}, },
}); });
// rate-limit 플러그인 등록 (특정 라우트에만 적용)
await fastify.register(rateLimit, {
global: false,
});
// 플러그인 등록 (순서 중요) // 플러그인 등록 (순서 중요)
await fastify.register(dbPlugin); await fastify.register(dbPlugin);
await fastify.register(redisPlugin); await fastify.register(redisPlugin);
@ -110,7 +117,7 @@ export async function buildApp(opts = {}) {
// 헬스 체크 엔드포인트 // 헬스 체크 엔드포인트
fastify.get('/api/health', async () => { fastify.get('/api/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() }; return { status: 'ok', timestamp: nowKST() };
}); });
// 봇 상태 조회 엔드포인트 // 봇 상태 조회 엔드포인트

View file

@ -1,4 +1,11 @@
export default [ export default [
{
id: 'meilisearch-sync',
type: 'meilisearch',
name: 'Meilisearch 동기화',
cron: '0 4 * * *', // 4시부터 5분간 버전 체크, 변경 시 동기화
enabled: true,
},
{ {
id: 'youtube-fromis9', id: 'youtube-fromis9',
type: 'youtube', type: 'youtube',

View file

@ -5,6 +5,14 @@ export const CATEGORY_IDS = {
BIRTHDAY: 8, BIRTHDAY: 8,
}; };
// 필수 환경변수 검증
const requiredEnvVars = ['JWT_SECRET'];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
throw new Error(`필수 환경변수 ${envVar}가 설정되지 않았습니다.`);
}
}
export default { export default {
server: { server: {
port: parseInt(process.env.PORT) || 80, port: parseInt(process.env.PORT) || 80,
@ -34,7 +42,7 @@ export default {
apiKey: process.env.YOUTUBE_API_KEY, apiKey: process.env.YOUTUBE_API_KEY,
}, },
jwt: { jwt: {
secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026', secret: process.env.JWT_SECRET,
expiresIn: '30d', expiresIn: '30d',
}, },
s3: { s3: {

View file

@ -1,8 +1,11 @@
import fp from 'fastify-plugin'; import fp from 'fastify-plugin';
import cron from 'node-cron'; import cron from 'node-cron';
import bots from '../config/bots.js'; import bots from '../config/bots.js';
import { syncWithRetry, getVersion } from '../services/meilisearch/index.js';
import { nowKST } from '../utils/date.js';
const REDIS_PREFIX = 'bot:status:'; const REDIS_PREFIX = 'bot:status:';
const TIMEZONE = 'Asia/Seoul';
async function schedulerPlugin(fastify, opts) { async function schedulerPlugin(fastify, opts) {
const tasks = new Map(); const tasks = new Map();
@ -12,7 +15,7 @@ async function schedulerPlugin(fastify, opts) {
*/ */
async function updateStatus(botId, status) { async function updateStatus(botId, status) {
const current = await getStatus(botId); const current = await getStatus(botId);
const updated = { ...current, ...status, updatedAt: new Date().toISOString() }; const updated = { ...current, ...status, updatedAt: nowKST() };
await fastify.redis.set(`${REDIS_PREFIX}${botId}`, JSON.stringify(updated)); await fastify.redis.set(`${REDIS_PREFIX}${botId}`, JSON.stringify(updated));
return updated; return updated;
} }
@ -30,6 +33,7 @@ async function schedulerPlugin(fastify, opts) {
lastCheckAt: null, lastCheckAt: null,
lastAddedCount: 0, lastAddedCount: 0,
totalAdded: 0, totalAdded: 0,
lastSyncDuration: null,
errorMessage: null, errorMessage: null,
}; };
} }
@ -42,10 +46,123 @@ async function schedulerPlugin(fastify, opts) {
return fastify.youtubeBot.syncNewVideos; return fastify.youtubeBot.syncNewVideos;
} else if (bot.type === 'x') { } else if (bot.type === 'x') {
return fastify.xBot.syncNewTweets; return fastify.xBot.syncNewTweets;
} else if (bot.type === 'meilisearch') {
return async () => {
const count = await syncWithRetry(fastify.meilisearch, fastify.db);
return { addedCount: count, total: count };
};
} }
return null; return null;
} }
/**
* Meilisearch 버전 체크 동기화 (업데이트 감지용)
*/
async function startMeilisearchVersionCheck(botId, bot) {
const REDIS_VERSION_KEY = 'meilisearch:version';
const CHECK_INTERVAL = 60 * 1000; // 1분
const CHECK_DURATION = 5 * 60 * 1000; // 5분간 체크
// 체크 시작 cron (매일 4시 KST)
const task = cron.schedule(bot.cron, async () => {
fastify.log.info(`[${botId}] 버전 체크 시작 (5분간 1분 간격)`);
await updateStatus(botId, { status: 'running' });
const startTime = Date.now();
let synced = false;
let checkCount = 0;
// 초기 버전 저장
const initialVersion = await getVersion(fastify.meilisearch);
if (!initialVersion) {
fastify.log.error(`[${botId}] Meilisearch 연결 실패`);
await updateStatus(botId, { status: 'error', errorMessage: 'Meilisearch 연결 실패' });
return;
}
const savedVersion = await fastify.redis.get(REDIS_VERSION_KEY);
fastify.log.info(`[${botId}] 현재 버전: ${initialVersion}, 저장된 버전: ${savedVersion || '없음'}`);
// 버전이 이미 다르면 즉시 동기화
if (savedVersion && savedVersion !== initialVersion) {
fastify.log.info(`[${botId}] 버전 변경 감지! ${savedVersion}${initialVersion}`);
await performSync(botId, initialVersion, REDIS_VERSION_KEY);
return;
}
// 5분간 1분 간격으로 체크
const intervalId = setInterval(async () => {
checkCount++;
const elapsed = Date.now() - startTime;
if (synced || elapsed >= CHECK_DURATION) {
clearInterval(intervalId);
if (!synced) {
fastify.log.info(`[${botId}] 버전 변경 없음, 체크 종료`);
await updateStatus(botId, {
status: 'running',
lastCheckAt: nowKST(),
});
}
return;
}
const currentVersion = await getVersion(fastify.meilisearch);
fastify.log.info(`[${botId}] 체크 #${checkCount}: 버전 ${currentVersion}`);
if (currentVersion && currentVersion !== initialVersion) {
synced = true;
clearInterval(intervalId);
fastify.log.info(`[${botId}] 버전 변경 감지! ${initialVersion}${currentVersion}`);
await performSync(botId, currentVersion, REDIS_VERSION_KEY);
}
}, CHECK_INTERVAL);
}, { timezone: TIMEZONE });
tasks.set(botId, task);
await updateStatus(botId, { status: 'running' });
fastify.log.info(`[${botId}] 버전 체크 스케줄 시작 (cron: ${bot.cron})`);
// 초기 버전 저장 (최초 실행 시)
const currentVersion = await getVersion(fastify.meilisearch);
if (currentVersion) {
const savedVersion = await fastify.redis.get(REDIS_VERSION_KEY);
if (!savedVersion) {
await fastify.redis.set(REDIS_VERSION_KEY, currentVersion);
fastify.log.info(`[${botId}] 초기 버전 저장: ${currentVersion}`);
}
}
}
/**
* 동기화 실행 상태 업데이트
*/
async function performSync(botId, newVersion, versionKey) {
const startTime = Date.now();
try {
const count = await syncWithRetry(fastify.meilisearch, fastify.db);
const duration = Date.now() - startTime;
await fastify.redis.set(versionKey, newVersion);
await updateStatus(botId, {
status: 'running',
lastCheckAt: nowKST(),
lastAddedCount: count,
lastSyncDuration: duration,
errorMessage: null,
});
fastify.log.info(`[${botId}] 동기화 완료: ${count}개, ${duration}ms, 새 버전: ${newVersion}`);
} catch (err) {
const duration = Date.now() - startTime;
await updateStatus(botId, {
status: 'error',
lastCheckAt: nowKST(),
lastSyncDuration: duration,
errorMessage: err.message,
});
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);
}
}
/** /**
* 동기화 결과 처리 (중복 코드 제거) * 동기화 결과 처리 (중복 코드 제거)
*/ */
@ -53,7 +170,7 @@ async function schedulerPlugin(fastify, opts) {
const { setRunningStatus = false, setErrorOnFail = false } = options; const { setRunningStatus = false, setErrorOnFail = false } = options;
const status = await getStatus(botId); const status = await getStatus(botId);
const updateData = { const updateData = {
lastCheckAt: new Date().toISOString(), lastCheckAt: nowKST(),
totalAdded: (status.totalAdded || 0) + result.addedCount, totalAdded: (status.totalAdded || 0) + result.addedCount,
}; };
if (setRunningStatus) { if (setRunningStatus) {
@ -82,12 +199,18 @@ async function schedulerPlugin(fastify, opts) {
tasks.delete(botId); tasks.delete(botId);
} }
// Meilisearch는 버전 체크 방식 사용
if (bot.type === 'meilisearch') {
await startMeilisearchVersionCheck(botId, bot);
return;
}
const syncFn = getSyncFunction(bot); const syncFn = getSyncFunction(bot);
if (!syncFn) { if (!syncFn) {
throw new Error(`지원하지 않는 봇 타입: ${bot.type}`); throw new Error(`지원하지 않는 봇 타입: ${bot.type}`);
} }
// cron 태스크 등록 // cron 태스크 등록 (한국 시간 기준)
const task = cron.schedule(bot.cron, async () => { const task = cron.schedule(bot.cron, async () => {
fastify.log.info(`[${botId}] 동기화 시작`); fastify.log.info(`[${botId}] 동기화 시작`);
try { try {
@ -97,24 +220,26 @@ async function schedulerPlugin(fastify, opts) {
} catch (err) { } catch (err) {
await updateStatus(botId, { await updateStatus(botId, {
status: 'error', status: 'error',
lastCheckAt: new Date().toISOString(), lastCheckAt: nowKST(),
errorMessage: err.message, errorMessage: err.message,
}); });
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`); fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);
} }
}); }, { timezone: TIMEZONE });
tasks.set(botId, task); tasks.set(botId, task);
await updateStatus(botId, { status: 'running' }); await updateStatus(botId, { status: 'running' });
fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`); fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`);
// 즉시 1회 실행 // 즉시 1회 실행 (meilisearch는 스케줄 시간에만 실행)
try { if (bot.type !== 'meilisearch') {
const result = await syncFn(bot); try {
const addedCount = await handleSyncResult(botId, result); const result = await syncFn(bot);
fastify.log.info(`[${botId}] 초기 동기화 완료: ${addedCount}개 추가`); const addedCount = await handleSyncResult(botId, result);
} catch (err) { fastify.log.info(`[${botId}] 초기 동기화 완료: ${addedCount}개 추가`);
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`); } catch (err) {
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
}
} }
} }
@ -175,5 +300,5 @@ async function schedulerPlugin(fastify, opts) {
export default fp(schedulerPlugin, { export default fp(schedulerPlugin, {
name: 'scheduler', name: 'scheduler',
dependencies: ['db', 'redis', 'youtubeBot', 'xBot'], dependencies: ['db', 'redis', 'meilisearch', 'youtubeBot', 'xBot'],
}); });

View file

@ -1,5 +1,8 @@
import bots from '../../config/bots.js'; import bots from '../../config/bots.js';
import { errorResponse } from '../../schemas/index.js'; import { errorResponse } from '../../schemas/index.js';
import { syncAllSchedules } from '../../services/meilisearch/index.js';
import { badRequest, notFound, serverError } from '../../utils/error.js';
import { nowKST } from '../../utils/date.js';
// 봇 관련 스키마 // 봇 관련 스키마
const botResponse = { const botResponse = {
@ -7,10 +10,11 @@ const botResponse = {
properties: { properties: {
id: { type: 'string' }, id: { type: 'string' },
name: { type: 'string' }, name: { type: 'string' },
type: { type: 'string', enum: ['youtube', 'x'] }, type: { type: 'string', enum: ['youtube', 'x', 'meilisearch'] },
status: { type: 'string', enum: ['running', 'stopped', 'error'] }, status: { type: 'string', enum: ['running', 'stopped', 'error'] },
last_check_at: { type: 'string', format: 'date-time' }, last_check_at: { type: 'string', format: 'date-time' },
last_added_count: { type: 'integer' }, last_added_count: { type: 'integer' },
last_sync_duration: { type: 'integer', description: '마지막 동기화 소요 시간 (ms)' },
schedules_added: { type: 'integer' }, schedules_added: { type: 'integer' },
check_interval: { type: 'integer' }, check_interval: { type: 'integer' },
error_message: { type: 'string' }, error_message: { type: 'string' },
@ -65,18 +69,27 @@ export default async function botsRoutes(fastify) {
checkInterval = parseInt(cronMatch[1]); checkInterval = parseInt(cronMatch[1]);
} }
result.push({ const botData = {
id: bot.id, id: bot.id,
name: bot.channelName || bot.username || bot.id, name: bot.name || bot.channelName || bot.username || bot.id,
type: bot.type, type: bot.type,
status: status.status, status: status.status,
last_check_at: status.lastCheckAt, last_check_at: status.lastCheckAt,
last_added_count: status.lastAddedCount, last_added_count: status.lastAddedCount,
last_sync_duration: status.lastSyncDuration,
schedules_added: status.totalAdded, schedules_added: status.totalAdded,
check_interval: checkInterval, check_interval: checkInterval,
error_message: status.errorMessage, error_message: status.errorMessage,
enabled: bot.enabled, enabled: bot.enabled,
}); };
// Meilisearch 봇인 경우 버전 정보 추가
if (bot.type === 'meilisearch') {
const version = await redis.get('meilisearch:version');
botData.version = version || '-';
}
result.push(botData);
} }
return result; return result;
@ -112,7 +125,7 @@ export default async function botsRoutes(fastify) {
await scheduler.startBot(id); await scheduler.startBot(id);
return { success: true, message: '봇이 시작되었습니다.' }; return { success: true, message: '봇이 시작되었습니다.' };
} catch (err) { } catch (err) {
return reply.code(400).send({ error: err.message }); return badRequest(reply, err.message);
} }
}); });
@ -146,7 +159,7 @@ export default async function botsRoutes(fastify) {
await scheduler.stopBot(id); await scheduler.stopBot(id);
return { success: true, message: '봇이 정지되었습니다.' }; return { success: true, message: '봇이 정지되었습니다.' };
} catch (err) { } catch (err) {
return reply.code(400).send({ error: err.message }); return badRequest(reply, err.message);
} }
}); });
@ -181,27 +194,34 @@ export default async function botsRoutes(fastify) {
const bot = bots.find(b => b.id === id); const bot = bots.find(b => b.id === id);
if (!bot) { if (!bot) {
return reply.code(404).send({ error: '봇을 찾을 수 없습니다.' }); return notFound(reply, '봇을 찾을 수 없습니다.');
} }
const startTime = Date.now();
try { try {
let result; let result;
if (bot.type === 'youtube') { if (bot.type === 'youtube') {
result = await fastify.youtubeBot.syncAllVideos(bot); result = await fastify.youtubeBot.syncAllVideos(bot);
} else if (bot.type === 'x') { } else if (bot.type === 'x') {
result = await fastify.xBot.syncAllTweets(bot); result = await fastify.xBot.syncAllTweets(bot);
} else if (bot.type === 'meilisearch') {
const count = await syncAllSchedules(fastify.meilisearch, fastify.db);
result = { addedCount: count, total: count };
} else { } else {
return reply.code(400).send({ error: '지원하지 않는 봇 타입입니다.' }); return badRequest(reply, '지원하지 않는 봇 타입입니다.');
} }
const duration = Date.now() - startTime;
// 상태 업데이트 // 상태 업데이트
const status = await scheduler.getStatus(id); const status = await scheduler.getStatus(id);
await fastify.redis.set(`bot:status:${id}`, JSON.stringify({ await fastify.redis.set(`bot:status:${id}`, JSON.stringify({
...status, ...status,
lastCheckAt: new Date().toISOString(), lastCheckAt: nowKST(),
lastAddedCount: result.addedCount, lastAddedCount: result.addedCount,
lastSyncDuration: duration,
totalAdded: (status.totalAdded || 0) + result.addedCount, totalAdded: (status.totalAdded || 0) + result.addedCount,
updatedAt: new Date().toISOString(), updatedAt: nowKST(),
})); }));
return { return {
@ -211,7 +231,7 @@ export default async function botsRoutes(fastify) {
}; };
} catch (err) { } catch (err) {
fastify.log.error(`[${id}] 전체 동기화 오류:`, err); fastify.log.error(`[${id}] 전체 동기화 오류:`, err);
return reply.code(500).send({ error: err.message }); return serverError(reply, err.message);
} }
}); });

View file

@ -7,6 +7,7 @@ import {
xPostInfoQuery, xPostInfoQuery,
xScheduleCreate, xScheduleCreate,
} from '../../schemas/index.js'; } from '../../schemas/index.js';
import { badRequest, conflict, serverError } from '../../utils/error.js';
const X_CATEGORY_ID = CATEGORY_IDS.X; const X_CATEGORY_ID = CATEGORY_IDS.X;
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080'; const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
@ -61,7 +62,7 @@ export default async function xRoutes(fastify) {
// 게시글 ID 유효성 검사 // 게시글 ID 유효성 검사
if (!/^\d+$/.test(postId)) { if (!/^\d+$/.test(postId)) {
return reply.code(400).send({ error: '유효하지 않은 게시글 ID입니다.' }); return badRequest(reply, '유효하지 않은 게시글 ID입니다.');
} }
try { try {
@ -80,7 +81,7 @@ export default async function xRoutes(fastify) {
}; };
} catch (err) { } catch (err) {
fastify.log.error(`X 게시글 조회 오류: ${err.message}`); fastify.log.error(`X 게시글 조회 오류: ${err.message}`);
return reply.code(500).send({ error: err.message }); return serverError(reply, err.message);
} }
}); });
@ -118,7 +119,7 @@ export default async function xRoutes(fastify) {
[postId] [postId]
); );
if (existing.length > 0) { if (existing.length > 0) {
return reply.code(409).send({ error: '이미 등록된 게시글입니다.' }); return conflict(reply, '이미 등록된 게시글입니다.');
} }
// schedules 테이블에 저장 // schedules 테이블에 저장
@ -155,7 +156,7 @@ export default async function xRoutes(fastify) {
return { success: true, scheduleId }; return { success: true, scheduleId };
} catch (err) { } catch (err) {
fastify.log.error(`X 일정 저장 오류: ${err.message}`); fastify.log.error(`X 일정 저장 오류: ${err.message}`);
return reply.code(500).send({ error: err.message }); return serverError(reply, err.message);
} }
}); });
} }

View file

@ -8,6 +8,7 @@ import {
youtubeScheduleUpdate, youtubeScheduleUpdate,
idParam, idParam,
} from '../../schemas/index.js'; } from '../../schemas/index.js';
import { badRequest, notFound, conflict, serverError } from '../../utils/error.js';
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
@ -54,13 +55,13 @@ export default async function youtubeRoutes(fastify) {
// YouTube URL에서 video ID 추출 // YouTube URL에서 video ID 추출
const videoId = extractVideoId(url); const videoId = extractVideoId(url);
if (!videoId) { if (!videoId) {
return reply.code(400).send({ error: '유효하지 않은 YouTube URL입니다.' }); return badRequest(reply, '유효하지 않은 YouTube URL입니다.');
} }
try { try {
const video = await fetchVideoInfo(videoId); const video = await fetchVideoInfo(videoId);
if (!video) { if (!video) {
return reply.code(404).send({ error: '영상을 찾을 수 없습니다.' }); return notFound(reply, '영상을 찾을 수 없습니다.');
} }
return { return {
@ -76,7 +77,7 @@ export default async function youtubeRoutes(fastify) {
}; };
} catch (err) { } catch (err) {
fastify.log.error(`YouTube 영상 조회 오류: ${err.message}`); fastify.log.error(`YouTube 영상 조회 오류: ${err.message}`);
return reply.code(500).send({ error: err.message }); return serverError(reply, err.message);
} }
}); });
@ -114,7 +115,7 @@ export default async function youtubeRoutes(fastify) {
[videoId] [videoId]
); );
if (existing.length > 0) { if (existing.length > 0) {
return reply.code(409).send({ error: '이미 등록된 영상입니다.' }); return conflict(reply, '이미 등록된 영상입니다.');
} }
// schedules 테이블에 저장 // schedules 테이블에 저장
@ -151,7 +152,7 @@ export default async function youtubeRoutes(fastify) {
return { success: true, scheduleId }; return { success: true, scheduleId };
} catch (err) { } catch (err) {
fastify.log.error(`YouTube 일정 저장 오류: ${err.message}`); fastify.log.error(`YouTube 일정 저장 오류: ${err.message}`);
return reply.code(500).send({ error: err.message }); return serverError(reply, err.message);
} }
}); });
@ -190,7 +191,7 @@ export default async function youtubeRoutes(fastify) {
[id, YOUTUBE_CATEGORY_ID] [id, YOUTUBE_CATEGORY_ID]
); );
if (schedules.length === 0) { if (schedules.length === 0) {
return reply.code(404).send({ error: 'YouTube 일정을 찾을 수 없습니다.' }); return notFound(reply, 'YouTube 일정을 찾을 수 없습니다.');
} }
// 영상 유형 수정 // 영상 유형 수정
@ -254,7 +255,7 @@ export default async function youtubeRoutes(fastify) {
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
fastify.log.error(`YouTube 일정 수정 오류: ${err.message}`); fastify.log.error(`YouTube 일정 수정 오류: ${err.message}`);
return reply.code(500).send({ error: err.message }); return serverError(reply, err.message);
} }
}); });
} }

View file

@ -11,6 +11,7 @@ import {
import photosRoutes from './photos.js'; import photosRoutes from './photos.js';
import teasersRoutes from './teasers.js'; import teasersRoutes from './teasers.js';
import { errorResponse, successResponse, idParam } from '../../schemas/index.js'; import { errorResponse, successResponse, idParam } from '../../schemas/index.js';
import { notFound, badRequest } from '../../utils/error.js';
/** /**
* 앨범 라우트 * 앨범 라우트
@ -67,7 +68,7 @@ export default async function albumsRoutes(fastify) {
const album = await getAlbumByName(db, albumName); const album = await getAlbumByName(db, albumName);
if (!album) { if (!album) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); return notFound(reply, '앨범을 찾을 수 없습니다.');
} }
const [tracks] = await db.query( const [tracks] = await db.query(
@ -76,7 +77,7 @@ export default async function albumsRoutes(fastify) {
); );
if (tracks.length === 0) { if (tracks.length === 0) {
return reply.code(404).send({ error: '트랙을 찾을 수 없습니다.' }); return notFound(reply, '트랙을 찾을 수 없습니다.');
} }
const track = tracks[0]; const track = tracks[0];
@ -123,7 +124,7 @@ export default async function albumsRoutes(fastify) {
}, async (request, reply) => { }, async (request, reply) => {
const album = await getAlbumByName(db, decodeURIComponent(request.params.name)); const album = await getAlbumByName(db, decodeURIComponent(request.params.name));
if (!album) { if (!album) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); return notFound(reply, '앨범을 찾을 수 없습니다.');
} }
return getAlbumDetails(db, album, redis); return getAlbumDetails(db, album, redis);
}); });
@ -144,7 +145,7 @@ export default async function albumsRoutes(fastify) {
}, async (request, reply) => { }, async (request, reply) => {
const album = await getAlbumById(db, request.params.id); const album = await getAlbumById(db, request.params.id);
if (!album) { if (!album) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); return notFound(reply, '앨범을 찾을 수 없습니다.');
} }
return getAlbumDetails(db, album, redis); return getAlbumDetails(db, album, redis);
}); });
@ -182,18 +183,22 @@ export default async function albumsRoutes(fastify) {
if (part.type === 'file' && part.fieldname === 'cover') { if (part.type === 'file' && part.fieldname === 'cover') {
coverBuffer = await part.toBuffer(); coverBuffer = await part.toBuffer();
} else if (part.fieldname === 'data') { } else if (part.fieldname === 'data') {
data = JSON.parse(part.value); try {
data = JSON.parse(part.value);
} catch {
return badRequest(reply, '잘못된 JSON 형식입니다.');
}
} }
} }
if (!data) { if (!data) {
return reply.code(400).send({ error: '데이터가 필요합니다.' }); return badRequest(reply, '데이터가 필요합니다.');
} }
const { title, album_type, release_date, folder_name } = data; const { title, album_type, release_date, folder_name } = data;
if (!title || !album_type || !release_date || !folder_name) { if (!title || !album_type || !release_date || !folder_name) {
return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' }); return badRequest(reply, '필수 필드를 모두 입력해주세요.');
} }
const result = await createAlbum(db, data, coverBuffer); const result = await createAlbum(db, data, coverBuffer);
@ -229,17 +234,21 @@ export default async function albumsRoutes(fastify) {
if (part.type === 'file' && part.fieldname === 'cover') { if (part.type === 'file' && part.fieldname === 'cover') {
coverBuffer = await part.toBuffer(); coverBuffer = await part.toBuffer();
} else if (part.fieldname === 'data') { } else if (part.fieldname === 'data') {
data = JSON.parse(part.value); try {
data = JSON.parse(part.value);
} catch {
return badRequest(reply, '잘못된 JSON 형식입니다.');
}
} }
} }
if (!data) { if (!data) {
return reply.code(400).send({ error: '데이터가 필요합니다.' }); return badRequest(reply, '데이터가 필요합니다.');
} }
const result = await updateAlbum(db, id, data, coverBuffer); const result = await updateAlbum(db, id, data, coverBuffer);
if (!result) { if (!result) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); return notFound(reply, '앨범을 찾을 수 없습니다.');
} }
await invalidateAlbumCache(redis, id); await invalidateAlbumCache(redis, id);
return result; return result;
@ -265,7 +274,7 @@ export default async function albumsRoutes(fastify) {
const { id } = request.params; const { id } = request.params;
const result = await deleteAlbum(db, id); const result = await deleteAlbum(db, id);
if (!result) { if (!result) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); return notFound(reply, '앨범을 찾을 수 없습니다.');
} }
await invalidateAlbumCache(redis, id); await invalidateAlbumCache(redis, id);
return result; return result;

View file

@ -4,6 +4,7 @@ import {
uploadAlbumVideo, uploadAlbumVideo,
} from '../../services/image.js'; } from '../../services/image.js';
import { withTransaction } from '../../utils/transaction.js'; import { withTransaction } from '../../utils/transaction.js';
import { notFound } from '../../utils/error.js';
/** /**
* 앨범 사진 라우트 * 앨범 사진 라우트
@ -25,7 +26,7 @@ export default async function photosRoutes(fastify) {
const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]); const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]);
if (albums.length === 0) { if (albums.length === 0) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); return notFound(reply, '앨범을 찾을 수 없습니다.');
} }
const [photos] = await db.query( const [photos] = await db.query(
@ -96,7 +97,13 @@ export default async function photosRoutes(fastify) {
const buffer = await part.toBuffer(); const buffer = await part.toBuffer();
files.push({ buffer, mimetype: part.mimetype }); files.push({ buffer, mimetype: part.mimetype });
} else if (part.fieldname === 'metadata') { } else if (part.fieldname === 'metadata') {
metadata = JSON.parse(part.value); try {
metadata = JSON.parse(part.value);
} catch {
reply.raw.write(`data: ${JSON.stringify({ error: '잘못된 metadata JSON 형식입니다.' })}\n\n`);
reply.raw.end();
return;
}
} else if (part.fieldname === 'startNumber') { } else if (part.fieldname === 'startNumber') {
startNumber = parseInt(part.value) || null; startNumber = parseInt(part.value) || null;
} else if (part.fieldname === 'photoType') { } else if (part.fieldname === 'photoType') {
@ -227,7 +234,7 @@ export default async function photosRoutes(fastify) {
); );
if (photos.length === 0) { if (photos.length === 0) {
return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' }); return notFound(reply, '사진을 찾을 수 없습니다.');
} }
const photo = photos[0]; const photo = photos[0];

View file

@ -3,6 +3,7 @@ import {
deleteAlbumVideo, deleteAlbumVideo,
} from '../../services/image.js'; } from '../../services/image.js';
import { withTransaction } from '../../utils/transaction.js'; import { withTransaction } from '../../utils/transaction.js';
import { notFound } from '../../utils/error.js';
/** /**
* 앨범 티저 라우트 * 앨범 티저 라우트
@ -24,7 +25,7 @@ export default async function teasersRoutes(fastify) {
const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]); const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]);
if (albums.length === 0) { if (albums.length === 0) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); return notFound(reply, '앨범을 찾을 수 없습니다.');
} }
const [teasers] = await db.query( const [teasers] = await db.query(
@ -61,7 +62,7 @@ export default async function teasersRoutes(fastify) {
); );
if (teasers.length === 0) { if (teasers.length === 0) {
return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' }); return notFound(reply, '티저를 찾을 수 없습니다.');
} }
const teaser = teasers[0]; const teaser = teasers[0];

View file

@ -1,4 +1,5 @@
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { badRequest, unauthorized, serverError } from '../utils/error.js';
/** /**
* 인증 라우트 * 인증 라우트
@ -10,6 +11,19 @@ export default async function authRoutes(fastify, opts) {
* 관리자 로그인 * 관리자 로그인
*/ */
fastify.post('/login', { fastify.post('/login', {
config: {
rateLimit: {
max: 5,
timeWindow: '1 minute',
continueExceeding: true, // 차단 중 시도하면 타이머 리셋 (마지막 시도 기준 1분)
keyGenerator: (request) => request.ip,
errorResponseBuilder: () => ({
statusCode: 429,
error: 'Too Many Requests',
message: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.',
}),
},
},
schema: { schema: {
tags: ['auth'], tags: ['auth'],
summary: '관리자 로그인', summary: '관리자 로그인',
@ -36,13 +50,21 @@ export default async function authRoutes(fastify, opts) {
}, },
}, },
}, },
429: {
type: 'object',
properties: {
statusCode: { type: 'integer' },
error: { type: 'string' },
message: { type: 'string' },
},
},
}, },
}, },
}, async (request, reply) => { }, async (request, reply) => {
const { username, password } = request.body || {}; const { username, password } = request.body || {};
if (!username || !password) { if (!username || !password) {
return reply.code(400).send({ error: '아이디와 비밀번호를 입력해주세요.' }); return badRequest(reply, '아이디와 비밀번호를 입력해주세요.');
} }
try { try {
@ -52,14 +74,14 @@ export default async function authRoutes(fastify, opts) {
); );
if (users.length === 0) { if (users.length === 0) {
return reply.code(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' }); return unauthorized(reply, '아이디 또는 비밀번호가 올바르지 않습니다.');
} }
const user = users[0]; const user = users[0];
const isValidPassword = await bcrypt.compare(password, user.password_hash); const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) { if (!isValidPassword) {
return reply.code(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' }); return unauthorized(reply, '아이디 또는 비밀번호가 올바르지 않습니다.');
} }
// JWT 토큰 생성 // JWT 토큰 생성
@ -75,7 +97,7 @@ export default async function authRoutes(fastify, opts) {
}; };
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '로그인 처리 중 오류가 발생했습니다.' }); return serverError(reply, '로그인 처리 중 오류가 발생했습니다.');
} }
}); });

View file

@ -1,5 +1,6 @@
import { uploadMemberImage } from '../../services/image.js'; import { uploadMemberImage } from '../../services/image.js';
import { getAllMembers, getMemberByName, getMemberBasicByName, invalidateMemberCache } from '../../services/member.js'; import { getAllMembers, getMemberByName, getMemberBasicByName, invalidateMemberCache } from '../../services/member.js';
import { notFound, serverError } from '../../utils/error.js';
/** /**
* 멤버 라우트 * 멤버 라우트
@ -22,7 +23,7 @@ export default async function membersRoutes(fastify, opts) {
return await getAllMembers(db, redis); return await getAllMembers(db, redis);
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '멤버 목록 조회 실패' }); return serverError(reply, '멤버 목록 조회 실패');
} }
}); });
@ -45,12 +46,12 @@ export default async function membersRoutes(fastify, opts) {
try { try {
const member = await getMemberByName(db, decodeURIComponent(request.params.name)); const member = await getMemberByName(db, decodeURIComponent(request.params.name));
if (!member) { if (!member) {
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' }); return notFound(reply, '멤버를 찾을 수 없습니다');
} }
return member; return member;
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '멤버 조회 실패' }); return serverError(reply, '멤버 조회 실패');
} }
}); });
@ -80,7 +81,7 @@ export default async function membersRoutes(fastify, opts) {
// 기존 멤버 조회 // 기존 멤버 조회
const existing = await getMemberBasicByName(db, decodedName); const existing = await getMemberBasicByName(db, decodedName);
if (!existing) { if (!existing) {
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' }); return notFound(reply, '멤버를 찾을 수 없습니다');
} }
const memberId = existing.id; const memberId = existing.id;
@ -161,7 +162,7 @@ export default async function membersRoutes(fastify, opts) {
return { message: '멤버 정보가 수정되었습니다', id: memberId }; return { message: '멤버 정보가 수정되었습니다', id: memberId };
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '멤버 수정 실패: ' + err.message }); return serverError(reply, '멤버 수정 실패: ' + err.message);
} }
}); });
} }

View file

@ -3,7 +3,7 @@
* GET: 공개, POST/PUT/DELETE: 인증 필요 * GET: 공개, POST/PUT/DELETE: 인증 필요
*/ */
import suggestionsRoutes from './suggestions.js'; import suggestionsRoutes from './suggestions.js';
import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/index.js'; import { searchSchedules, syncAllSchedules, deleteSchedule } from '../../services/meilisearch/index.js';
import { CATEGORY_IDS } from '../../config/index.js'; import { CATEGORY_IDS } from '../../config/index.js';
import { import {
getCategories, getCategories,
@ -17,6 +17,8 @@ import {
scheduleSearchResponse, scheduleSearchResponse,
idParam, idParam,
} from '../../schemas/index.js'; } from '../../schemas/index.js';
import { badRequest, notFound, serverError } from '../../utils/error.js';
import { withTransaction } from '../../utils/transaction.js';
export default async function schedulesRoutes(fastify) { export default async function schedulesRoutes(fastify) {
const { db, meilisearch, redis } = fastify; const { db, meilisearch, redis } = fastify;
@ -42,7 +44,7 @@ export default async function schedulesRoutes(fastify) {
return await getCategories(db, redis); return await getCategories(db, redis);
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '카테고리 목록 조회 실패' }); return serverError(reply, '카테고리 목록 조회 실패');
} }
}); });
@ -82,13 +84,13 @@ export default async function schedulesRoutes(fastify) {
// 월별 조회 모드 // 월별 조회 모드
if (!year || !month) { if (!year || !month) {
return reply.code(400).send({ error: 'search, startDate, 또는 year/month는 필수입니다.' }); return badRequest(reply, 'search, startDate, 또는 year/month는 필수입니다.');
} }
return await getMonthlySchedules(db, parseInt(year), parseInt(month)); return await getMonthlySchedules(db, parseInt(year), parseInt(month));
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '일정 조회 실패' }); return serverError(reply, '일정 조회 실패');
} }
}); });
@ -119,7 +121,7 @@ export default async function schedulesRoutes(fastify) {
return { success: true, synced: count }; return { success: true, synced: count };
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '동기화 실패' }); return serverError(reply, '동기화 실패');
} }
}); });
@ -146,13 +148,13 @@ export default async function schedulesRoutes(fastify) {
); );
if (!result) { if (!result) {
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' }); return notFound(reply, '일정을 찾을 수 없습니다.');
} }
return result; return result;
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '일정 상세 조회 실패' }); return serverError(reply, '일정 상세 조회 실패');
} }
}); });
@ -182,33 +184,31 @@ export default async function schedulesRoutes(fastify) {
try { try {
const { id } = request.params; const { id } = request.params;
// 일정 존재 확인 // 일정 존재 확인 - 트랜잭션 전에 수행
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]); const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
if (existing.length === 0) { if (existing.length === 0) {
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' }); return notFound(reply, '일정을 찾을 수 없습니다.');
} }
// 관련 테이블 삭제 (외래 키) // 트랜잭션으로 DELETE 작업 수행
await db.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]); await withTransaction(db, async (connection) => {
await db.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]); // 관련 테이블 삭제 (외래 키)
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]); await connection.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]);
await db.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]); await connection.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]);
await connection.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
await connection.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]);
// 메인 테이블 삭제 // 메인 테이블 삭제
await db.query('DELETE FROM schedules WHERE id = ?', [id]); await connection.query('DELETE FROM schedules WHERE id = ?', [id]);
});
// Meilisearch에서도 삭제 // Meilisearch에서도 삭제 (트랜잭션 외부, 실패해도 무시)
try { await deleteSchedule(meilisearch, id);
const { deleteSchedule } = await import('../../services/meilisearch/index.js');
await deleteSchedule(meilisearch, id);
} catch (meiliErr) {
fastify.log.error(`Meilisearch 삭제 오류: ${meiliErr.message}`);
}
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '일정 삭제 실패' }); return serverError(reply, '일정 삭제 실패');
} }
}); });
} }

View file

@ -1,9 +1,10 @@
/** /**
* 추천 검색어 API 라우트 * 추천 검색어 API 라우트
*/ */
import { readFileSync, writeFileSync } from 'fs'; import { readFile, writeFile } from 'fs/promises';
import { SuggestionService } from '../../services/suggestions/index.js'; import { SuggestionService } from '../../services/suggestions/index.js';
import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js'; import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js';
import { badRequest, serverError } from '../../utils/error.js';
let suggestionService = null; let suggestionService = null;
@ -109,7 +110,7 @@ export default async function suggestionsRoutes(fastify) {
const { query } = request.body; const { query } = request.body;
if (!query || query.trim().length === 0) { if (!query || query.trim().length === 0) {
return reply.code(400).send({ error: '검색어가 필요합니다.' }); return badRequest(reply, '검색어가 필요합니다.');
} }
await suggestionService.saveSearchQuery(query); await suggestionService.saveSearchQuery(query);
@ -138,7 +139,7 @@ export default async function suggestionsRoutes(fastify) {
}, async (request, reply) => { }, async (request, reply) => {
try { try {
const dictPath = getUserDictPath(); const dictPath = getUserDictPath();
const content = readFileSync(dictPath, 'utf-8'); const content = await readFile(dictPath, 'utf-8');
return { content }; return { content };
} catch (error) { } catch (error) {
if (error.code === 'ENOENT') { if (error.code === 'ENOENT') {
@ -179,7 +180,7 @@ export default async function suggestionsRoutes(fastify) {
try { try {
const dictPath = getUserDictPath(); const dictPath = getUserDictPath();
writeFileSync(dictPath, content, 'utf-8'); await writeFile(dictPath, content, 'utf-8');
// 형태소 분석기 리로드 // 형태소 분석기 리로드
await reloadMorpheme(); await reloadMorpheme();
@ -187,7 +188,7 @@ export default async function suggestionsRoutes(fastify) {
return { message: '사전이 저장되었습니다.' }; return { message: '사전이 저장되었습니다.' };
} catch (error) { } catch (error) {
fastify.log.error(`[Suggestions] 사전 저장 오류: ${error.message}`); fastify.log.error(`[Suggestions] 사전 저장 오류: ${error.message}`);
return reply.code(500).send({ error: '사전 저장 중 오류가 발생했습니다.' }); return serverError(reply, '사전 저장 중 오류가 발생했습니다.');
} }
}); });
} }

View file

@ -2,6 +2,8 @@
* 통계 라우트 * 통계 라우트
* 인증 필요 * 인증 필요
*/ */
import { serverError } from '../../utils/error.js';
export default async function statsRoutes(fastify, opts) { export default async function statsRoutes(fastify, opts) {
const { db } = fastify; const { db } = fastify;
@ -70,7 +72,7 @@ export default async function statsRoutes(fastify, opts) {
}; };
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return reply.code(500).send({ error: '통계 조회 실패' }); return serverError(reply, '통계 조회 실패');
} }
}); });
} }

View file

@ -25,7 +25,8 @@ export const scheduleResponse = {
properties: { properties: {
id: { type: 'integer' }, id: { type: 'integer' },
title: { type: 'string' }, title: { type: 'string' },
datetime: { type: 'string' }, date: { type: 'string', format: 'date' },
time: { type: 'string', nullable: true },
category: scheduleCategory, category: scheduleCategory,
members: { type: 'array', items: scheduleMember }, members: { type: 'array', items: scheduleMember },
createdAt: { type: 'string', format: 'date-time' }, createdAt: { type: 'string', format: 'date-time' },

View file

@ -2,7 +2,7 @@
* 앨범 서비스 * 앨범 서비스
* 앨범 관련 비즈니스 로직 * 앨범 관련 비즈니스 로직
*/ */
import { uploadAlbumCover, deleteAlbumCover } from './image.js'; import { uploadAlbumCover, deleteAlbumCover, deleteAlbumPhoto, deleteAlbumVideo } from './image.js';
import { withTransaction } from '../utils/transaction.js'; import { withTransaction } from '../utils/transaction.js';
import { getOrSet, invalidate, invalidatePattern, cacheKeys, TTL } from '../utils/cache.js'; import { getOrSet, invalidate, invalidatePattern, cacheKeys, TTL } from '../utils/cache.js';
@ -283,6 +283,17 @@ export async function updateAlbum(db, id, data, coverBuffer) {
}); });
} }
/**
* URL에서 파일명 추출
* @param {string} url - S3 URL
* @returns {string|null} 파일명
*/
function extractFilenameFromUrl(url) {
if (!url) return null;
const parts = url.split('/');
return parts[parts.length - 1];
}
/** /**
* 앨범 삭제 * 앨범 삭제
* @param {object} db - 데이터베이스 연결 * @param {object} db - 데이터베이스 연결
@ -297,14 +308,52 @@ export async function deleteAlbum(db, id) {
} }
const album = existingAlbums[0]; const album = existingAlbums[0];
const folderName = album.folder_name;
// S3 파일 삭제를 위해 사진/티저 목록 조회 (트랜잭션 외부)
const [[photos], [teasers]] = await Promise.all([
db.query('SELECT original_url FROM album_photos WHERE album_id = ?', [id]),
db.query('SELECT original_url, video_url FROM album_teasers WHERE album_id = ?', [id]),
]);
return withTransaction(db, async (connection) => { return withTransaction(db, async (connection) => {
// S3 파일 삭제 (병렬 처리)
const s3DeletePromises = [];
// 커버 이미지 삭제 // 커버 이미지 삭제
if (album.cover_original_url && album.folder_name) { if (album.cover_original_url && folderName) {
await deleteAlbumCover(album.folder_name); s3DeletePromises.push(deleteAlbumCover(folderName));
} }
// 관련 데이터 삭제 // 사진 삭제
for (const photo of photos) {
const filename = extractFilenameFromUrl(photo.original_url);
if (filename && folderName) {
s3DeletePromises.push(deleteAlbumPhoto(folderName, 'photo', filename));
}
}
// 티저 삭제 (이미지 + 비디오)
for (const teaser of teasers) {
const filename = extractFilenameFromUrl(teaser.original_url);
if (filename && folderName) {
s3DeletePromises.push(deleteAlbumPhoto(folderName, 'teaser', filename));
}
const videoFilename = extractFilenameFromUrl(teaser.video_url);
if (videoFilename && folderName) {
s3DeletePromises.push(deleteAlbumVideo(folderName, videoFilename));
}
}
await Promise.all(s3DeletePromises);
// DB 관련 데이터 삭제 (순서 중요: FK 제약조건)
await connection.query(
'DELETE FROM album_photo_members WHERE photo_id IN (SELECT id FROM album_photos WHERE album_id = ?)',
[id]
);
await connection.query('DELETE FROM album_photos WHERE album_id = ?', [id]);
await connection.query('DELETE FROM album_teasers WHERE album_id = ?', [id]);
await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]); await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]);
await connection.query('DELETE FROM albums WHERE id = ?', [id]); await connection.query('DELETE FROM albums WHERE id = ?', [id]);

View file

@ -8,7 +8,6 @@
import Inko from 'inko'; import Inko from 'inko';
import config, { CATEGORY_IDS } from '../../config/index.js'; import config, { CATEGORY_IDS } from '../../config/index.js';
import { createLogger } from '../../utils/logger.js'; import { createLogger } from '../../utils/logger.js';
import { buildDatetime } from '../schedule.js';
const inko = new Inko(); const inko = new Inko();
const logger = createLogger('Meilisearch'); const logger = createLogger('Meilisearch');
@ -166,7 +165,8 @@ function formatScheduleResponse(hit) {
return { return {
id: hit.id, id: hit.id,
title: hit.title, title: hit.title,
datetime: buildDatetime(hit.date, hit.time), date: hit.date,
time: hit.time || null,
category: { category: {
id: hit.category_id, id: hit.category_id,
name: hit.category_name, name: hit.category_name,
@ -251,10 +251,7 @@ export async function syncAllSchedules(meilisearch, db) {
const index = meilisearch.index(INDEX_NAME); const index = meilisearch.index(INDEX_NAME);
// 기존 문서 모두 삭제 // 문서 변환 (addDocuments는 같은 ID면 자동 업데이트)
await index.deleteAllDocuments();
// 문서 변환
const documents = schedules.map(s => ({ const documents = schedules.map(s => ({
id: s.id, id: s.id,
title: s.title, title: s.title,
@ -278,3 +275,76 @@ export async function syncAllSchedules(meilisearch, db) {
return 0; return 0;
} }
} }
/**
* Meilisearch 버전 조회
*/
export async function getVersion(meilisearch) {
try {
const version = await meilisearch.getVersion();
return version.pkgVersion;
} catch (err) {
logger.error(`버전 조회 오류: ${err.message}`);
return null;
}
}
/**
* 인덱스 삭제 재생성
*/
async function recreateIndex(meilisearch) {
const index = meilisearch.index(INDEX_NAME);
try {
// 인덱스 삭제
const deleteTask = await meilisearch.deleteIndex(INDEX_NAME);
await meilisearch.waitForTask(deleteTask.taskUid);
logger.info('기존 인덱스 삭제 완료');
} catch (err) {
// 인덱스가 없으면 무시
}
// 인덱스 재생성
const createTask = await meilisearch.createIndex(INDEX_NAME, { primaryKey: 'id' });
await meilisearch.waitForTask(createTask.taskUid);
// 설정 복원
await index.updateSearchableAttributes([
'title', 'member_names', 'description', 'source_name', 'category_name',
]);
await index.updateFilterableAttributes(['category_id', 'date']);
await index.updateSortableAttributes(['date', 'time']);
await index.updateRankingRules([
'words', 'typo', 'proximity', 'attribute', 'exactness', 'date:desc',
]);
await index.updateTypoTolerance({
enabled: true,
minWordSizeForTypos: { oneTypo: 2, twoTypos: 4 },
});
await index.updatePagination({ maxTotalHits: 10000 });
logger.info('인덱스 재생성 완료');
}
/**
* 동기화 (오류 인덱스 재생성 재시도)
*/
export async function syncWithRetry(meilisearch, db) {
try {
const count = await syncAllSchedules(meilisearch, db);
if (count > 0) return count;
// 0개면 오류일 수 있으므로 재시도
throw new Error('동기화 결과 0개');
} catch (err) {
logger.warn(`동기화 실패, 인덱스 재생성 후 재시도: ${err.message}`);
try {
await recreateIndex(meilisearch);
return await syncAllSchedules(meilisearch, db);
} catch (retryErr) {
logger.error(`재시도 실패: ${retryErr.message}`);
return 0;
}
}
}

View file

@ -69,7 +69,8 @@ export function formatSchedule(rawSchedule, members = []) {
return { return {
id: rawSchedule.id, id: rawSchedule.id,
title: rawSchedule.title, title: rawSchedule.title,
datetime: buildDatetime(rawSchedule.date, rawSchedule.time), date: normalizeDate(rawSchedule.date),
time: rawSchedule.time || null,
category: { category: {
id: rawSchedule.category_id, id: rawSchedule.category_id,
name: rawSchedule.category_name, name: rawSchedule.category_name,
@ -220,7 +221,8 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
const result = { const result = {
id: s.id, id: s.id,
title: s.title, title: s.title,
datetime: buildDatetime(s.date, s.time), date: normalizeDate(s.date),
time: s.time || null,
category: { category: {
id: s.category_id, id: s.category_id,
name: s.category_name, name: s.category_name,
@ -324,7 +326,8 @@ export async function getMonthlySchedules(db, year, month) {
schedules.push({ schedules.push({
id: `birthday-${member.id}`, id: `birthday-${member.id}`,
title: `HAPPY ${member.name_en} DAY`, title: `HAPPY ${member.name_en} DAY`,
datetime: birthdayDate.toISOString().split('T')[0], date: birthdayDate.toISOString().split('T')[0],
time: null,
category: { category: {
id: CATEGORY_IDS.BIRTHDAY, id: CATEGORY_IDS.BIRTHDAY,
name: '생일', name: '생일',
@ -338,7 +341,7 @@ export async function getMonthlySchedules(db, year, month) {
} }
// 날짜순 정렬 // 날짜순 정렬
schedules.sort((a, b) => a.datetime.localeCompare(b.datetime)); schedules.sort((a, b) => a.date.localeCompare(b.date));
return { schedules }; return { schedules };
} }

View file

@ -1,8 +1,9 @@
import fp from 'fastify-plugin'; import fp from 'fastify-plugin';
import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js'; import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
import { fetchVideoInfo } from '../youtube/api.js'; import { fetchVideoInfo } from '../youtube/api.js';
import { formatDate, formatTime } from '../../utils/date.js'; import { formatDate, formatTime, nowKST } from '../../utils/date.js';
import bots from '../../config/bots.js'; import bots from '../../config/bots.js';
import { withTransaction } from '../../utils/transaction.js';
const X_CATEGORY_ID = 3; const X_CATEGORY_ID = 3;
const YOUTUBE_CATEGORY_ID = 2; const YOUTUBE_CATEGORY_ID = 2;
@ -40,7 +41,7 @@ async function xBotPlugin(fastify, opts) {
username, username,
displayName: profile.displayName, displayName: profile.displayName,
avatarUrl: profile.avatarUrl, avatarUrl: profile.avatarUrl,
updatedAt: new Date().toISOString(), updatedAt: nowKST(),
}; };
await fastify.redis.setex( await fastify.redis.setex(
`${PROFILE_CACHE_PREFIX}${username}`, `${PROFILE_CACHE_PREFIX}${username}`,
@ -53,7 +54,7 @@ async function xBotPlugin(fastify, opts) {
* 트윗을 DB에 저장 * 트윗을 DB에 저장
*/ */
async function saveTweet(tweet) { async function saveTweet(tweet) {
// 중복 체크 (post_id로) // 중복 체크 (post_id로) - 트랜잭션 전에 수행
const [existing] = await fastify.db.query( const [existing] = await fastify.db.query(
'SELECT id FROM schedule_x WHERE post_id = ?', 'SELECT id FROM schedule_x WHERE post_id = ?',
[tweet.id] [tweet.id]
@ -66,32 +67,35 @@ async function xBotPlugin(fastify, opts) {
const time = formatTime(tweet.time); const time = formatTime(tweet.time);
const title = extractTitle(tweet.text); const title = extractTitle(tweet.text);
// schedules 테이블에 저장 // 트랜잭션으로 INSERT 작업 수행
const [result] = await fastify.db.query( return withTransaction(fastify.db, async (connection) => {
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', // schedules 테이블에 저장
[X_CATEGORY_ID, title, date, time] const [result] = await connection.query(
); 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
const scheduleId = result.insertId; [X_CATEGORY_ID, title, date, time]
);
const scheduleId = result.insertId;
// schedule_x 테이블에 저장 // schedule_x 테이블에 저장
await fastify.db.query( await connection.query(
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)', 'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
[ [
scheduleId, scheduleId,
tweet.id, tweet.id,
tweet.text, tweet.text,
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null, tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
] ]
); );
return scheduleId; return scheduleId;
});
} }
/** /**
* YouTube 영상을 DB에 저장 (트윗에서 감지된 링크) * YouTube 영상을 DB에 저장 (트윗에서 감지된 링크)
*/ */
async function saveYoutubeFromTweet(video) { async function saveYoutubeFromTweet(video) {
// 중복 체크 // 중복 체크 - 트랜잭션 전에 수행
const [existing] = await fastify.db.query( const [existing] = await fastify.db.query(
'SELECT id FROM schedule_youtube WHERE video_id = ?', 'SELECT id FROM schedule_youtube WHERE video_id = ?',
[video.videoId] [video.videoId]
@ -100,20 +104,23 @@ async function xBotPlugin(fastify, opts) {
return null; return null;
} }
// schedules 테이블에 저장 // 트랜잭션으로 INSERT 작업 수행
const [result] = await fastify.db.query( return withTransaction(fastify.db, async (connection) => {
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', // schedules 테이블에 저장
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time] const [result] = await connection.query(
); 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
const scheduleId = result.insertId; [YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
);
const scheduleId = result.insertId;
// schedule_youtube 테이블에 저장 // schedule_youtube 테이블에 저장
await fastify.db.query( await connection.query(
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
[scheduleId, video.videoId, video.videoType, video.channelId, video.channelTitle] [scheduleId, video.videoId, video.videoType, video.channelId, video.channelTitle]
); );
return scheduleId; return scheduleId;
});
} }
/** /**

View file

@ -1,5 +1,32 @@
import { parseNitterDateTime } from '../../utils/date.js'; import { parseNitterDateTime } from '../../utils/date.js';
const FETCH_TIMEOUT = 10000; // 10초
/**
* 타임아웃이 적용된 fetch
*/
async function fetchWithTimeout(url, timeout = FETCH_TIMEOUT) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res;
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error('요청 타임아웃');
}
throw err;
}
}
/** /**
* 트윗 텍스트에서 문단 추출 (title용) * 트윗 텍스트에서 문단 추출 (title용)
*/ */
@ -196,7 +223,7 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
*/ */
export async function fetchTweets(nitterUrl, username) { export async function fetchTweets(nitterUrl, username) {
const url = `${nitterUrl}/${username}`; const url = `${nitterUrl}/${username}`;
const res = await fetch(url); const res = await fetchWithTimeout(url);
const html = await res.text(); const html = await res.text();
// 프로필 정보 // 프로필 정보
@ -225,7 +252,7 @@ export async function fetchAllTweets(nitterUrl, username, log) {
log?.info(`[페이지 ${pageNum}] 스크래핑 중...`); log?.info(`[페이지 ${pageNum}] 스크래핑 중...`);
try { try {
const res = await fetch(url); const res = await fetchWithTimeout(url);
const html = await res.text(); const html = await res.text();
const tweets = parseTweets(html, username); const tweets = parseTweets(html, username);

View file

@ -2,6 +2,7 @@ import fp from 'fastify-plugin';
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js'; import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
import bots from '../../config/bots.js'; import bots from '../../config/bots.js';
import { CATEGORY_IDS } from '../../config/index.js'; import { CATEGORY_IDS } from '../../config/index.js';
import { withTransaction } from '../../utils/transaction.js';
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:'; const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
@ -56,7 +57,7 @@ async function youtubeBotPlugin(fastify, opts) {
* 영상을 DB에 저장 * 영상을 DB에 저장
*/ */
async function saveVideo(video, bot) { async function saveVideo(video, bot) {
// 중복 체크 (video_id로) // 중복 체크 (video_id로) - 트랜잭션 전에 수행
const [existing] = await fastify.db.query( const [existing] = await fastify.db.query(
'SELECT id FROM schedule_youtube WHERE video_id = ?', 'SELECT id FROM schedule_youtube WHERE video_id = ?',
[video.videoId] [video.videoId]
@ -70,40 +71,48 @@ async function youtubeBotPlugin(fastify, opts) {
return null; return null;
} }
// schedules 테이블에 저장 // 멤버 이름 맵 미리 조회 (트랜잭션 전에)
const [result] = await fastify.db.query( let nameMap = null;
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', if (bot.extractMembersFromDesc) {
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time] nameMap = await getMemberNameMap();
);
const scheduleId = result.insertId;
// schedule_youtube 테이블에 저장
await fastify.db.query(
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
[scheduleId, video.videoId, video.videoType, video.channelId, bot.channelName]
);
// 멤버 연결 (커스텀 설정)
if (bot.defaultMemberId || bot.extractMembersFromDesc) {
const memberIds = [];
if (bot.defaultMemberId) {
memberIds.push(bot.defaultMemberId);
}
if (bot.extractMembersFromDesc) {
const nameMap = await getMemberNameMap();
memberIds.push(...extractMemberIds(video.description, nameMap));
}
if (memberIds.length > 0) {
const uniqueIds = [...new Set(memberIds)];
const values = uniqueIds.map(id => [scheduleId, id]);
await fastify.db.query(
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
[values]
);
}
} }
return scheduleId; // 트랜잭션으로 INSERT 작업 수행
return withTransaction(fastify.db, async (connection) => {
// schedules 테이블에 저장
const [result] = await connection.query(
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
);
const scheduleId = result.insertId;
// schedule_youtube 테이블에 저장
await connection.query(
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
[scheduleId, video.videoId, video.videoType, video.channelId, bot.channelName]
);
// 멤버 연결 (커스텀 설정)
if (bot.defaultMemberId || bot.extractMembersFromDesc) {
const memberIds = [];
if (bot.defaultMemberId) {
memberIds.push(bot.defaultMemberId);
}
if (nameMap) {
memberIds.push(...extractMemberIds(video.description, nameMap));
}
if (memberIds.length > 0) {
const uniqueIds = [...new Set(memberIds)];
const values = uniqueIds.map(id => [scheduleId, id]);
await connection.query(
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
[values]
);
}
}
return scheduleId;
});
} }
/** /**

View file

@ -41,15 +41,19 @@ export async function invalidate(redis, keys) {
} }
/** /**
* 패턴으로 캐시 무효화 * 패턴으로 캐시 무효화 (SCAN 사용으로 블로킹 방지)
* @param {object} redis - Redis 클라이언트 * @param {object} redis - Redis 클라이언트
* @param {string} pattern - 패턴 (: 'schedule:*') * @param {string} pattern - 패턴 (: 'schedule:*')
*/ */
export async function invalidatePattern(redis, pattern) { export async function invalidatePattern(redis, pattern) {
const keys = await redis.keys(pattern); let cursor = '0';
if (keys.length > 0) { do {
await redis.del(...keys); const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
} cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
} }
// 캐시 키 생성 헬퍼 // 캐시 키 생성 헬퍼

View file

@ -28,6 +28,14 @@ export function formatTime(date) {
return dayjs(date).tz(KST).format('HH:mm:ss'); return dayjs(date).tz(KST).format('HH:mm:ss');
} }
/**
* 현재 KST 시간을 ISO 형식으로 반환
* : "2025-01-23T13:05:00+09:00"
*/
export function nowKST() {
return dayjs().tz(KST).format();
}
/** /**
* Nitter 날짜 문자열 파싱 * Nitter 날짜 문자열 파싱
* : "Jan 15, 2026 · 10:30 PM UTC" * : "Jan 15, 2026 · 10:30 PM UTC"

View file

@ -2,6 +2,7 @@
* 로거 유틸리티 * 로거 유틸리티
* 서비스 레이어에서 사용할 있는 간단한 로깅 유틸리티 * 서비스 레이어에서 사용할 있는 간단한 로깅 유틸리티
*/ */
import { nowKST } from './date.js';
const PREFIX = { const PREFIX = {
info: '[INFO]', info: '[INFO]',
@ -11,7 +12,7 @@ const PREFIX = {
}; };
function formatMessage(level, context, message) { function formatMessage(level, context, message) {
const timestamp = new Date().toISOString(); const timestamp = nowKST();
return `${timestamp} ${PREFIX[level]} [${context}] ${message}`; return `${timestamp} ${PREFIX[level]} [${context}] ${message}`;
} }

View file

@ -12,19 +12,6 @@ services:
- app - app
restart: unless-stopped 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: fromis9-backend:
build: ./backend build: ./backend
container_name: fromis9-backend container_name: fromis9-backend
@ -40,7 +27,7 @@ services:
restart: unless-stopped restart: unless-stopped
meilisearch: meilisearch:
image: getmeili/meilisearch:v1.6 image: getmeili/meilisearch:latest
container_name: fromis9-meilisearch container_name: fromis9-meilisearch
environment: environment:
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY} - MEILI_MASTER_KEY=${MEILI_MASTER_KEY}

View file

@ -7,6 +7,8 @@ Base URL: `/api`
### POST /auth/login ### POST /auth/login
로그인 (JWT 토큰 발급) 로그인 (JWT 토큰 발급)
**Rate Limit:** 1분당 5회 (IP 기준)
### GET /auth/verify ### GET /auth/verify
토큰 검증 및 사용자 정보 (인증 필요) 토큰 검증 및 사용자 정보 (인증 필요)
@ -48,25 +50,24 @@ Base URL: `/api`
**월별 조회 응답:** **월별 조회 응답:**
```json ```json
{ {
"2026-01-18": { "schedules": [
"categories": [ {
{ "id": 2, "name": "유튜브", "color": "#ff0033", "count": 3 } "id": 123,
], "title": "...",
"schedules": [ "date": "2026-01-18",
{ "time": "19:00:00",
"id": 123, "category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
"title": "...", "source": {
"time": "19:00:00", "name": "fromis_9",
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" }, "url": "https://www.youtube.com/watch?v=VIDEO_ID"
"source": { },
"name": "fromis_9", "members": ["송하영"]
"url": "https://www.youtube.com/watch?v=VIDEO_ID" }
} ]
}
]
}
} }
``` ```
`time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
```
**source 객체 (카테고리별):** **source 객체 (카테고리별):**
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }` - YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
@ -75,20 +76,22 @@ Base URL: `/api`
**다가오는 일정 응답 (startDate):** **다가오는 일정 응답 (startDate):**
```json ```json
[ {
{ "schedules": [
"id": 123, {
"title": "...", "id": 123,
"date": "2026-01-18", "title": "...",
"time": "19:00:00", "date": "2026-01-18",
"category_id": 2, "time": "19:00:00",
"category_name": "유튜브", "category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
"category_color": "#ff0033", "source": { "name": "fromis_9", "url": "https://..." },
"members": [{ "name": "송하영" }] "members": ["송하영"]
} }
] ]
}
``` ```
※ 현재 활동 멤버 전원인 경우 `[{ "name": "프로미스나인" }]` 반환 (탈퇴 멤버 제외) ※ 현재 활동 멤버 전원인 경우 `["프로미스나인"]` 반환 (탈퇴 멤버 제외)
`time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
**검색 응답:** **검색 응답:**
```json ```json
@ -97,7 +100,8 @@ Base URL: `/api`
{ {
"id": 123, "id": 123,
"title": "...", "title": "...",
"datetime": "2026-01-18T19:00:00", "date": "2026-01-18",
"time": "19:00:00",
"category": { "id": 2, "name": "유튜브", "color": "#ff0033" }, "category": { "id": 2, "name": "유튜브", "color": "#ff0033" },
"source": { "name": "fromis_9", "url": "https://..." }, "source": { "name": "fromis_9", "url": "https://..." },
"members": ["송하영"], "members": ["송하영"],
@ -110,6 +114,8 @@ Base URL: `/api`
"hasMore": true "hasMore": true
} }
``` ```
`time`: 시간이 없는 일정은 `null`, 00:00 시간은 `"00:00:00"`으로 반환
```
### GET /schedules/categories ### GET /schedules/categories
카테고리 목록 조회 카테고리 목록 조회
@ -207,16 +213,36 @@ Meilisearch 전체 동기화 (인증 필요)
"name": "fromis_9", "name": "fromis_9",
"type": "youtube", "type": "youtube",
"status": "running", "status": "running",
"last_check_at": "2026-01-18T10:30:00Z", "last_check_at": "2026-01-18T19:30:00+09:00",
"last_added_count": 2, "last_added_count": 2,
"last_sync_duration": 1234,
"schedules_added": 150, "schedules_added": 150,
"check_interval": 2, "check_interval": 2,
"error_message": null, "error_message": null,
"enabled": true "enabled": true
},
{
"id": "meilisearch-sync",
"name": "Meilisearch 동기화",
"type": "meilisearch",
"status": "running",
"last_check_at": "2026-01-18T04:00:00+09:00",
"last_added_count": 500,
"last_sync_duration": 2500,
"schedules_added": 500,
"check_interval": 0,
"error_message": null,
"enabled": true,
"version": "1.6.0"
} }
] ]
``` ```
**필드 설명:**
- `last_check_at`: 마지막 동기화 시간 (KST, +09:00)
- `last_sync_duration`: 마지막 동기화 소요 시간 (ms)
- `version`: Meilisearch 버전 (meilisearch 타입만)
### POST /admin/bots/:id/start ### POST /admin/bots/:id/start
봇 시작 봇 시작
@ -243,7 +269,7 @@ YouTube API 할당량 경고 조회
{ {
"active": true, "active": true,
"message": "YouTube API 할당량 초과", "message": "YouTube API 할당량 초과",
"timestamp": "2026-01-18T10:00:00Z" "timestamp": "2026-01-18T19:00:00+09:00"
} }
``` ```

View file

@ -4,7 +4,7 @@
``` ```
fromis_9/ fromis_9/
├── backend/ # Fastify 백엔드 (현재 사용) ├── backend/ # Fastify 백엔드
│ ├── src/ │ ├── src/
│ │ ├── config/ │ │ ├── config/
│ │ │ ├── index.js # 환경변수 통합 관리 │ │ │ ├── index.js # 환경변수 통합 관리
@ -38,33 +38,26 @@ fromis_9/
│ │ │ ├── x/ # X(Twitter) 봇 │ │ │ ├── x/ # X(Twitter) 봇
│ │ │ ├── meilisearch/ # 검색 서비스 │ │ │ ├── meilisearch/ # 검색 서비스
│ │ │ └── suggestions/ # 추천 검색어 │ │ │ └── suggestions/ # 추천 검색어
│ │ ├── utils/ # 유틸리티
│ │ │ ├── cache.js # Redis 캐시 헬퍼 (SCAN 사용)
│ │ │ ├── date.js # 날짜 유틸 (KST 변환)
│ │ │ ├── error.js # 에러 응답 헬퍼
│ │ │ ├── logger.js # 로깅 유틸
│ │ │ └── transaction.js # DB 트랜잭션 래퍼
│ │ ├── app.js # Fastify 앱 설정 │ │ ├── app.js # Fastify 앱 설정
│ │ └── server.js # 진입점 │ │ └── server.js # 진입점
│ ├── Dockerfile # 백엔드 컨테이너 │ ├── Dockerfile # 백엔드 컨테이너
│ └── package.json │ └── package.json
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본) ├── frontend/ # React 프론트엔드
├── frontend/ # React 프론트엔드 (레거시, frontend-temp로 대체 예정)
│ ├── src/ │ ├── src/
│ │ ├── api/ │ │ ├── api/ # API 클라이언트
│ │ │ ├── public/ # 공개 API
│ │ │ └── admin/ # 어드민 API
│ │ ├── pages/
│ │ │ ├── pc/ # PC 페이지
│ │ │ └── mobile/ # 모바일 페이지
│ │ └── ...
│ └── package.json
├── frontend-temp/ # React 프론트엔드 (신규, Strangler Fig 마이그레이션)
│ ├── src/
│ │ ├── api/ # API 클라이언트 (공유)
│ │ │ ├── index.js │ │ │ ├── index.js
│ │ │ ├── client.js # fetchApi, fetchAuthApi │ │ │ ├── client.js # fetchApi, fetchAuthApi
│ │ │ ├── albums.js │ │ │ ├── public/ # 공개 API
│ │ │ ├── members.js │ │ │ │ ├── albums.js
│ │ │ ├── schedules.js │ │ │ │ ├── members.js
│ │ │ ├── auth.js │ │ │ │ └── schedules.js
│ │ │ └── admin/ # 관리자 API │ │ │ └── admin/ # 관리자 API
│ │ │ ├── albums.js │ │ │ ├── albums.js
│ │ │ ├── members.js │ │ │ ├── members.js
@ -72,131 +65,174 @@ fromis_9/
│ │ │ ├── categories.js │ │ │ ├── categories.js
│ │ │ ├── stats.js │ │ │ ├── stats.js
│ │ │ ├── bots.js │ │ │ ├── bots.js
│ │ │ ├── auth.js
│ │ │ └── suggestions.js │ │ │ └── suggestions.js
│ │ │ │ │ │
│ │ ├── hooks/ # 커스텀 훅 (공유) │ │ ├── hooks/ # 커스텀 훅
│ │ │ ├── index.js │ │ │ ├── index.js
│ │ │ ├── useAlbumData.js │ │ │ ├── common/ # 공통 훅
│ │ │ ├── useMemberData.js │ │ │ │ └── useToast.js
│ │ │ ├── useScheduleData.js │ │ │ └── pc/
│ │ │ ├── useScheduleSearch.js │ │ │ └── admin/ # 관리자 훅
│ │ │ ├── useCalendar.js │ │ │ ├── useAdminAuth.js
│ │ │ ├── useToast.js │ │ │ └── useScheduleSearch.js
│ │ │ └── useAdminAuth.js
│ │ │ │ │ │
│ │ ├── stores/ # Zustand 스토어 (공유) │ │ ├── stores/ # Zustand 스토어
│ │ │ ├── index.js │ │ │ ├── index.js
│ │ │ ├── useScheduleStore.js │ │ │ ├── useScheduleStore.js
│ │ │ └── useAuthStore.js │ │ │ └── useAuthStore.js
│ │ │ │ │ │
│ │ ├── utils/ # 유틸리티 (공유) │ │ ├── utils/ # 유틸리티
│ │ │ ├── index.js │ │ │ ├── index.js
│ │ │ ├── date.js │ │ │ ├── cn.js # className 병합
│ │ │ └── format.js │ │ │ ├── color.js # 색상 상수/유틸
│ │ │ ├── confetti.js # 생일 축하 효과
│ │ │ ├── date.js # 날짜 포맷
│ │ │ ├── format.js # 문자열 포맷
│ │ │ ├── schedule.js # 일정 관련 유틸
│ │ │ └── youtube.js # YouTube URL 파싱
│ │ │ │ │ │
│ │ ├── constants/ │ │ ├── constants/
│ │ │ └── index.js │ │ │ └── index.js # 상수 정의
│ │ │ │ │ │
│ │ ├── components/ │ │ ├── components/
│ │ │ ├── index.js │ │ │ ├── index.js
│ │ │ ├── common/ # 디바이스 무관 공통 컴포넌트 │ │ │ ├── common/ # 공통 컴포넌트
│ │ │ │ ├── Loading.jsx │ │ │ │ ├── Loading.jsx
│ │ │ │ ├── ErrorBoundary.jsx │ │ │ │ ├── ErrorBoundary.jsx
│ │ │ │ ├── ErrorMessage.jsx
│ │ │ │ ├── Toast.jsx │ │ │ │ ├── Toast.jsx
│ │ │ │ ├── Lightbox.jsx
│ │ │ │ ├── LightboxIndicator.jsx
│ │ │ │ ├── Tooltip.jsx │ │ │ │ ├── Tooltip.jsx
│ │ │ │ ├── Lightbox.jsx
│ │ │ │ ├── MobileLightbox.jsx
│ │ │ │ ├── LightboxIndicator.jsx
│ │ │ │ ├── AnimatedNumber.jsx
│ │ │ │ └── ScrollToTop.jsx │ │ │ │ └── ScrollToTop.jsx
│ │ │ ├── pc/ # PC 레이아웃 컴포넌트 │ │ │ │
│ │ │ │ ├── Layout.jsx │ │ │ ├── pc/
│ │ │ │ ├── Header.jsx │ │ │ │ ├── public/ # PC 공개 컴포넌트
│ │ │ │ └── Footer.jsx │ │ │ │ │ ├── layout/
│ │ │ ├── mobile/ # Mobile 레이아웃 컴포넌트 │ │ │ │ │ │ ├── Layout.jsx
│ │ │ │ ├── Layout.jsx │ │ │ │ │ │ ├── Header.jsx
│ │ │ │ └── MobileNav.jsx │ │ │ │ │ │ └── Footer.jsx
│ │ │ └── admin/ # 관리자 컴포넌트 │ │ │ │ │ └── schedule/
│ │ │ ├── AdminLayout.jsx │ │ │ │ │ ├── Calendar.jsx
│ │ │ ├── AdminHeader.jsx │ │ │ │ │ ├── ScheduleCard.jsx
│ │ │ ├── ConfirmDialog.jsx │ │ │ │ │ ├── BirthdayCard.jsx
│ │ │ ├── CustomDatePicker.jsx │ │ │ │ │ └── CategoryFilter.jsx
│ │ │ ├── CustomTimePicker.jsx │ │ │ │ │
│ │ │ └── NumberPicker.jsx │ │ │ │ └── admin/ # PC 관리자 컴포넌트
│ │ │ │ ├── layout/
│ │ │ │ │ ├── Layout.jsx
│ │ │ │ │ └── Header.jsx
│ │ │ │ ├── common/
│ │ │ │ │ ├── ConfirmDialog.jsx
│ │ │ │ │ ├── DatePicker.jsx
│ │ │ │ │ ├── TimePicker.jsx
│ │ │ │ │ ├── NumberPicker.jsx
│ │ │ │ │ └── CustomSelect.jsx
│ │ │ │ ├── schedule/
│ │ │ │ │ ├── AdminScheduleCard.jsx
│ │ │ │ │ ├── ScheduleItem.jsx
│ │ │ │ │ ├── CategorySelector.jsx
│ │ │ │ │ ├── CategoryFormModal.jsx
│ │ │ │ │ ├── MemberSelector.jsx
│ │ │ │ │ ├── ImageUploader.jsx
│ │ │ │ │ ├── LocationSearchDialog.jsx
│ │ │ │ │ └── WordItem.jsx
│ │ │ │ ├── album/
│ │ │ │ │ ├── TrackItem.jsx
│ │ │ │ │ ├── PhotoGrid.jsx
│ │ │ │ │ ├── PhotoPreviewModal.jsx
│ │ │ │ │ ├── PendingFileItem.jsx
│ │ │ │ │ └── BulkEditPanel.jsx
│ │ │ │ └── bot/
│ │ │ │ └── BotCard.jsx
│ │ │ │
│ │ │ └── mobile/ # 모바일 컴포넌트
│ │ │ ├── layout/
│ │ │ │ ├── Layout.jsx
│ │ │ │ ├── Header.jsx
│ │ │ │ └── BottomNav.jsx
│ │ │ └── schedule/
│ │ │ ├── Calendar.jsx
│ │ │ ├── ScheduleCard.jsx
│ │ │ ├── ScheduleListCard.jsx
│ │ │ ├── ScheduleSearchCard.jsx
│ │ │ └── BirthdayCard.jsx
│ │ │ │ │ │
│ │ ├── pages/ │ │ ├── pages/
│ │ │ ├── index.js │ │ │ ├── pc/
│ │ │ │ ├── public/ # PC 공개 페이지
│ │ │ │ │ ├── home/
│ │ │ │ │ │ └── Home.jsx
│ │ │ │ │ ├── members/
│ │ │ │ │ │ └── Members.jsx
│ │ │ │ │ ├── album/
│ │ │ │ │ │ ├── Album.jsx
│ │ │ │ │ │ ├── AlbumDetail.jsx
│ │ │ │ │ │ ├── AlbumGallery.jsx
│ │ │ │ │ │ └── TrackDetail.jsx
│ │ │ │ │ ├── schedule/
│ │ │ │ │ │ ├── Schedule.jsx
│ │ │ │ │ │ ├── ScheduleDetail.jsx
│ │ │ │ │ │ ├── Birthday.jsx
│ │ │ │ │ │ └── sections/
│ │ │ │ │ │ ├── DefaultSection.jsx
│ │ │ │ │ │ ├── YoutubeSection.jsx
│ │ │ │ │ │ └── XSection.jsx
│ │ │ │ │ └── common/
│ │ │ │ │ └── NotFound.jsx
│ │ │ │ │
│ │ │ │ └── admin/ # PC 관리자 페이지
│ │ │ │ ├── Login.jsx
│ │ │ │ ├── Dashboard.jsx
│ │ │ │ ├── members/
│ │ │ │ │ ├── Members.jsx
│ │ │ │ │ └── MemberEdit.jsx
│ │ │ │ ├── albums/
│ │ │ │ │ ├── Albums.jsx
│ │ │ │ │ ├── AlbumForm.jsx
│ │ │ │ │ └── AlbumPhotos.jsx
│ │ │ │ └── schedules/
│ │ │ │ ├── Schedules.jsx
│ │ │ │ ├── ScheduleForm.jsx
│ │ │ │ ├── ScheduleDict.jsx
│ │ │ │ ├── ScheduleBots.jsx
│ │ │ │ ├── ScheduleCategory.jsx
│ │ │ │ ├── form/
│ │ │ │ │ ├── YouTubeForm.jsx
│ │ │ │ │ └── XForm.jsx
│ │ │ │ └── edit/
│ │ │ │ └── YouTubeEdit.jsx
│ │ │ │ │ │ │ │
│ │ │ ├── home/ │ │ │ └── mobile/ # 모바일 페이지
│ │ │ │ ├── index.js # export { PCHome, MobileHome } │ │ │ ├── home/
│ │ │ │ ├── pc/ │ │ │ │ └── Home.jsx
│ │ │ │ │ └── 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/ │ │ │ ├── members/
│ │ │ │ ├── List.jsx │ │ │ │ └── Members.jsx
│ │ │ │ └── Edit.jsx │ │ │ ├── album/
│ │ │ ├── albums/ │ │ │ │ ├── Album.jsx
│ │ │ │ ├── List.jsx │ │ │ │ ├── AlbumDetail.jsx
│ │ │ │ ├── Form.jsx │ │ │ │ ├── AlbumGallery.jsx
│ │ │ │ └── Photos.jsx │ │ │ │ └── TrackDetail.jsx
│ │ │ ├── schedules/ │ │ │ ├── schedule/
│ │ │ │ ├── List.jsx │ │ │ │ ├── Schedule.jsx
│ │ │ │ ├── Form.jsx │ │ │ │ └── ScheduleDetail.jsx
│ │ │ │ ├── YouTubeForm.jsx │ │ │ └── common/
│ │ │ │ ├── XForm.jsx │ │ │ └── NotFound.jsx
│ │ │ │ └── YouTubeEditForm.jsx
│ │ │ ├── categories/
│ │ │ │ └── List.jsx
│ │ │ ├── bots/
│ │ │ │ └── Manager.jsx
│ │ │ └── dict/
│ │ │ └── Manager.jsx
│ │ │ │ │ │
│ │ ├── App.jsx # BrowserView/MobileView 라우팅 │ │ ├── routes/ # 라우트 정의
│ │ │ ├── index.js # 라우트 export
│ │ │ ├── pc/
│ │ │ │ ├── admin/
│ │ │ │ │ └── index.jsx # PC 관리자 라우트
│ │ │ │ └── public/
│ │ │ │ └── index.jsx # PC 공개 라우트
│ │ │ └── mobile/
│ │ │ └── index.jsx # 모바일 라우트
│ │ │
│ │ ├── App.jsx # PC/모바일 분기
│ │ └── main.jsx │ │ └── main.jsx
│ │ │ │
│ ├── vite.config.js │ ├── vite.config.js

View file

@ -170,7 +170,8 @@ docker exec caddy caddy reload --config /etc/caddy/Caddyfile
``` ```
src/api/ src/api/
├── index.js # fetchApi 유틸 (에러 처리, 토큰 주입) ├── index.js # 전체 export
├── client.js # api, authApi 헬퍼 (에러 처리, 토큰 주입)
├── public/ # 공개 API (인증 불필요) ├── public/ # 공개 API (인증 불필요)
│ ├── albums.js # getAlbums, getAlbumByName, getTrack │ ├── albums.js # getAlbums, getAlbumByName, getTrack
│ ├── members.js # getMembers │ ├── members.js # getMembers
@ -179,20 +180,37 @@ src/api/
├── auth.js # login, verifyToken ├── auth.js # login, verifyToken
├── albums.js # createAlbum, updateAlbum, deleteAlbum, ... ├── albums.js # createAlbum, updateAlbum, deleteAlbum, ...
├── bots.js # getBots, startBot, stopBot, syncBot ├── bots.js # getBots, startBot, stopBot, syncBot
├── categories.js # getCategories ├── categories.js # getCategories, createCategory, updateCategory, ...
├── members.js # updateMember ├── members.js # updateMember
├── schedules.js # getYoutubeInfo, saveYoutube, getXInfo, saveX, ... ├── schedules.js # getYoutubeInfo, saveYoutube, getXInfo, saveX, ...
├── stats.js # getStats ├── stats.js # getStats
└── suggestions.js # getDict, saveDict └── suggestions.js # getDict, saveDict
``` ```
**client.js 헬퍼:**
```jsx
// 공개 API 헬퍼 (인증 불필요)
import { api } from '@/api/client';
api.get('/albums');
api.post('/schedules/suggestions/save', { query: '검색어' });
// 인증 API 헬퍼 (토큰 자동 주입)
import { authApi } from '@/api/client';
authApi.get('/admin/stats');
authApi.post('/admin/schedules', data);
authApi.put('/admin/albums/1', data);
authApi.del('/admin/schedules/1');
```
**사용 예시:** **사용 예시:**
```jsx ```jsx
// 공개 API // 공개 API
import { getSchedules, getSchedule } from '@/api/public/schedules'; import { getSchedules, getSchedule } from '@/api/public/schedules';
// 관리자 API // 관리자 API
import { getBots, startBot } from '@/api/admin/bots'; import * as botsApi from '@/api/admin/bots';
``` ```
### React Query 사용 (데이터 페칭) ### React Query 사용 (데이터 페칭)
@ -253,6 +271,6 @@ docker compose down && docker compose up -d --build
curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \ curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \
-H "Authorization: Bearer <token>" -H "Authorization: Bearer <token>"
# Redis 확인 # Redis 확인 (SCAN 사용 권장)
docker exec fromis9-redis redis-cli KEYS "*" docker exec fromis9-redis redis-cli SCAN 0 MATCH "*" COUNT 100
``` ```

View file

@ -1,273 +0,0 @@
# 일정 관리 페이지 개선 계획
## 대상 파일
| 파일 | 라인 수 | 역할 |
|------|---------|------|
| 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줄 | 카테고리 폼 모달 |

View file

@ -1,4 +0,0 @@
# 개발 모드
FROM node:20-alpine
WORKDIR /app
CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"]

View file

@ -1,22 +0,0 @@
<!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>

File diff suppressed because it is too large Load diff

View file

@ -1,44 +0,0 @@
{
"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"
}
}

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

View file

@ -1,193 +0,0 @@
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;

View file

@ -1,97 +0,0 @@
/**
* 관리자 앨범 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' });
}

View file

@ -1,37 +0,0 @@
/**
* 관리자 인증 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 }),
});
}

View file

@ -1,55 +0,0 @@
/**
* 관리자 관리 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' });
}

View file

@ -1,60 +0,0 @@
/**
* 관리자 카테고리 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 }),
});
}

View file

@ -1,31 +0,0 @@
/**
* 관리자 멤버 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');
}

View file

@ -1,102 +0,0 @@
/**
* 관리자 일정 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' });
}

View file

@ -1,12 +0,0 @@
/**
* 관리자 통계 API
*/
import { fetchAuthApi } from '@/api/client';
/**
* 대시보드 통계 조회
* @returns {Promise<object>}
*/
export async function getStats() {
return fetchAuthApi('/stats');
}

View file

@ -1,24 +0,0 @@
/**
* 관리자 추천 검색어 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 }),
});
}

View file

@ -1,16 +0,0 @@
/**
* 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';

View file

@ -1,101 +0,0 @@
/**
* 앨범 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' });
}

View file

@ -1,43 +0,0 @@
/**
* 멤버 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' });
}

View file

@ -1,169 +0,0 @@
/**
* 스케줄 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 }),
});
}

View file

@ -1,290 +0,0 @@
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;

View file

@ -1,57 +0,0 @@
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;

View file

@ -1,85 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 기본 스타일 */
body {
background-color: #fafafa;
color: #1a1a1a;
}
/* 스크롤바 스타일 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #548360;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #456e50;
}
/* View Transitions API - 앨범 커버 이미지 부드러운 전환 */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}
/* 앨범 커버 트랜지션 */
::view-transition-group(*) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(*) {
animation: fade-out 0.3s ease-out both;
}
::view-transition-new(*) {
animation: fade-in 0.3s ease-in both;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 라이트박스 스크롤바 숨기기 */
.lightbox-no-scrollbar::-webkit-scrollbar {
display: none;
}
/* 스크롤바 숨기기 유틸리티 */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Swiper autoHeight 지원 */
.swiper-slide {
height: auto !important;
}

View file

@ -1,23 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5
retry: 1,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

View file

@ -1,156 +0,0 @@
/* 모바일 전용 스타일 */
/* 모바일 html,body 스크롤 방지 */
html.mobile-layout,
html.mobile-layout body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
/* 모바일 레이아웃 컨테이너 */
.mobile-layout-container {
display: flex;
flex-direction: column;
height: 100dvh;
overflow: hidden;
}
/* 모바일 툴바 (기본 56px) */
.mobile-toolbar {
flex-shrink: 0;
background-color: #ffffff;
}
/* 일정 페이지 툴바 (헤더 + 날짜 선택기) */
.mobile-toolbar-schedule {
flex-shrink: 0;
background-color: #ffffff;
}
/* 하단 네비게이션 */
.mobile-bottom-nav {
flex-shrink: 0;
}
/* 컨텐츠 영역 - 스크롤 가능, 스크롤바 숨김 */
.mobile-content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.mobile-content::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* 모바일 safe-area 지원 (노치, 홈 인디케이터) */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.safe-area-top {
padding-top: env(safe-area-inset-top, 0);
}
/* 모바일 달력 스타일 */
.mobile-calendar-wrapper .react-calendar__navigation button:hover {
background-color: #f3f4f6;
border-radius: 0.5rem;
}
.mobile-calendar-wrapper .react-calendar__navigation__label {
font-weight: 600;
font-size: 0.875rem;
color: #374151;
}
.mobile-calendar-wrapper .react-calendar__month-view__weekdays {
text-align: center;
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
}
.mobile-calendar-wrapper .react-calendar__month-view__weekdays__weekday {
padding: 0.5rem 0;
}
.mobile-calendar-wrapper .react-calendar__month-view__weekdays__weekday abbr {
text-decoration: none;
}
/* 일요일 (빨간색) */
.mobile-calendar-wrapper
.react-calendar__month-view__weekdays__weekday:first-child {
color: #f87171;
}
/* 토요일 (파란색) */
.mobile-calendar-wrapper
.react-calendar__month-view__weekdays__weekday:last-child {
color: #60a5fa;
}
.mobile-calendar-wrapper .react-calendar__tile {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.25rem;
background: none;
border: none;
font-size: 0.75rem;
color: #374151;
}
.mobile-calendar-wrapper .react-calendar__tile:hover {
background-color: #f3f4f6;
border-radius: 9999px;
}
.mobile-calendar-wrapper .react-calendar__tile abbr {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
}
/* 이웃 달 날짜 (흐리게) */
.mobile-calendar-wrapper
.react-calendar__month-view__days__day--neighboringMonth {
color: #d1d5db;
}
/* 일요일 */
.mobile-calendar-wrapper .react-calendar__tile.sunday abbr {
color: #ef4444;
}
/* 토요일 */
.mobile-calendar-wrapper .react-calendar__tile.saturday abbr {
color: #3b82f6;
}
/* 오늘 */
.mobile-calendar-wrapper .react-calendar__tile--now abbr {
background-color: #548360;
color: white;
font-weight: 700;
}
/* 선택된 날짜 */
.mobile-calendar-wrapper .react-calendar__tile--active abbr {
background-color: #548360;
color: white;
}
.mobile-calendar-wrapper .react-calendar__tile--active:enabled:hover abbr,
.mobile-calendar-wrapper .react-calendar__tile--active:enabled:focus abbr {
background-color: #456e50;
}

View file

@ -1,238 +0,0 @@
import { motion } from 'framer-motion';
import { useState, useMemo, useRef, useEffect } from 'react';
import { Instagram, Calendar } from 'lucide-react';
import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/css';
import { useMembers } from '@/hooks';
/**
* Mobile 멤버 페이지 - 카드 스와이프 스타일
*/
function MobileMembers() {
const [currentIndex, setCurrentIndex] = useState(0);
const swiperRef = useRef(null);
const indicatorRef = useRef(null);
//
const { data: allMembers = [] } = useMembers();
// / ( , )
const members = useMemo(() => {
return [...allMembers].sort((a, b) => {
if (a.is_former !== b.is_former) {
return a.is_former ? 1 : -1;
}
return 0;
});
}, [allMembers]);
//
const calculateAge = (birthDate) => {
if (!birthDate) return null;
const birth = new Date(birthDate);
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < birth.getDate())
) {
age--;
}
return age;
};
//
useEffect(() => {
if (indicatorRef.current && members.length > 0) {
const container = indicatorRef.current;
const itemWidth = 64; // 52px + 12px
const containerWidth = container.offsetWidth;
const paddingLeft = 16; // px-4
const targetScroll =
paddingLeft + currentIndex * itemWidth + 26 - containerWidth / 2;
container.scrollTo({
left: Math.max(0, targetScroll),
behavior: 'smooth',
});
}
}, [currentIndex, members.length]);
//
const handleIndicatorClick = (index) => {
if (swiperRef.current) {
swiperRef.current.slideTo(index);
}
};
if (members.length === 0) {
return (
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
<p className="text-gray-400">멤버 정보가 없습니다</p>
</div>
);
}
return (
<div className="flex flex-col h-[calc(100dvh-120px)] overflow-hidden overscroll-none touch-none">
{/* 상단 썸네일 인디케이터 */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="bg-white shadow-sm"
>
<div
ref={indicatorRef}
className="flex gap-3 px-4 py-4 overflow-x-auto scrollbar-hide"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{members.map((member, index) => {
const isSelected = index === currentIndex;
const isFormer = member.is_former;
return (
<button
key={member.id}
onClick={() => handleIndicatorClick(index)}
className={`flex-shrink-0 w-[52px] h-[52px] rounded-full p-[2px] transition-all duration-200
${
isSelected
? 'ring-[2.5px] ring-primary shadow-[0_0_8px_rgba(var(--primary-rgb),0.35)]'
: 'ring-[1.5px] ring-gray-300'
}`}
>
<div
className={`w-full h-full rounded-full overflow-hidden bg-gray-200
${isFormer ? 'grayscale' : ''}`}
>
{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-300 text-white font-bold">
{member.name[0]}
</div>
)}
</div>
</button>
);
})}
</div>
</motion.div>
{/* 메인 카드 영역 */}
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2, ease: 'easeOut' }}
className="flex-1 overflow-visible"
>
<Swiper
onSwiper={(swiper) => {
swiperRef.current = swiper;
}}
onSlideChange={(swiper) => setCurrentIndex(swiper.activeIndex)}
slidesPerView={1.12}
centeredSlides={true}
spaceBetween={0}
className="h-full !overflow-visible [&>.swiper-wrapper]:!overflow-visible"
style={{ padding: '8px 0' }}
>
{members.map((member) => {
const isFormer = member.is_former;
const age = calculateAge(member.birth_date);
return (
<SwiperSlide
key={member.id}
className="!flex items-center justify-center"
>
{({ isActive }) => (
<div
className={`relative w-full h-full max-h-[calc(100%-16px)] rounded-3xl overflow-hidden shadow-xl
transition-transform duration-300
${isActive ? 'scale-100' : 'scale-[0.92]'}`}
>
{/* 배경 이미지 */}
{member.image_url ? (
<img
src={member.image_url}
alt={member.name}
className={`absolute inset-0 w-full h-full object-cover
${isFormer ? 'grayscale' : ''}`}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-gray-300 to-gray-400" />
)}
{/* 하단 그라데이션 오버레이 */}
<div className="absolute inset-x-0 bottom-0 h-[220px] bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{/* 전 멤버 라벨 */}
{isFormer && (
<div className="absolute top-4 right-4 px-3 py-1.5 bg-black/60 rounded-full">
<span className="text-white/70 text-xs font-medium">
멤버
</span>
</div>
)}
{/* 멤버 정보 */}
<div className="absolute inset-x-0 bottom-0 p-6">
{/* 이름 */}
<h2 className="text-[32px] font-bold text-white drop-shadow-lg">
{member.name}
</h2>
{/* 생일 정보 */}
{member.birth_date && (
<div className="flex items-center gap-1.5 mt-1.5 text-white/80">
<Calendar size={16} className="text-white/70" />
<span className="text-sm">
{member.birth_date
?.slice(0, 10)
.replaceAll('-', '.')}
</span>
{age && (
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-lg text-xs text-white font-medium">
{age}
</span>
)}
</div>
)}
{/* 인스타그램 버튼 */}
{!isFormer && member.instagram && (
<a
href={member.instagram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 mt-4 px-4 py-2.5
bg-gradient-to-r from-[#833AB4] via-[#E1306C] to-[#F77737]
rounded-full shadow-lg shadow-[#E1306C]/40
active:scale-95 transition-transform"
>
<Instagram size={18} className="text-white" />
<span className="text-white text-sm font-semibold">
Instagram
</span>
</a>
)}
</div>
</div>
)}
</SwiperSlide>
);
})}
</Swiper>
</motion.div>
</div>
);
}
export default MobileMembers;

View file

@ -1,14 +0,0 @@
/* PC 전용 스타일 - body.is-pc 클래스가 있을 때만 적용 */
/* PC에서는 body 스크롤 숨기고 내부 영역에서만 스크롤 */
html.is-pc,
body.is-pc {
height: 100%;
overflow: hidden;
}
/* PC 최소 너비 설정 */
body.is-pc #root {
min-width: 1440px;
height: 100%;
}

View file

@ -1,107 +0,0 @@
import { create } from 'zustand';
/**
* 스케줄 페이지 상태 스토어
* 메모리 기반 - SPA 페이지 이동 유지, 새로고침 초기화
*/
const useScheduleStore = create((set, get) => ({
// ===== 검색 관련 =====
searchInput: '',
searchTerm: '',
isSearchMode: false,
// ===== 필터 관련 =====
selectedCategories: [],
selectedMembers: [],
// ===== 날짜 관련 =====
selectedDate: undefined, // undefined: 오늘, null: 전체, Date: 특정 날짜
currentDate: new Date(),
// ===== 뷰 관련 =====
viewMode: 'list', // 'list' | 'calendar'
scrollPosition: 0,
// ===== 검색 액션 =====
setSearchInput: (value) => set({ searchInput: value }),
setSearchTerm: (value) => set({ searchTerm: value }),
setIsSearchMode: (value) => set({ isSearchMode: value }),
startSearch: (term) => {
set({
searchTerm: term,
isSearchMode: true,
selectedDate: null, // 검색 시 날짜 필터 해제
});
},
clearSearch: () => {
set({
searchInput: '',
searchTerm: '',
isSearchMode: false,
});
},
// ===== 필터 액션 =====
setSelectedCategories: (value) => set({ selectedCategories: value }),
setSelectedMembers: (value) => set({ selectedMembers: value }),
toggleCategory: (categoryId) => {
const { selectedCategories } = get();
const isSelected = selectedCategories.includes(categoryId);
set({
selectedCategories: isSelected
? selectedCategories.filter((id) => id !== categoryId)
: [...selectedCategories, categoryId],
});
},
toggleMember: (memberId) => {
const { selectedMembers } = get();
const isSelected = selectedMembers.includes(memberId);
set({
selectedMembers: isSelected
? selectedMembers.filter((id) => id !== memberId)
: [...selectedMembers, memberId],
});
},
clearFilters: () => {
set({
selectedCategories: [],
selectedMembers: [],
});
},
// ===== 날짜 액션 =====
setSelectedDate: (value) => set({ selectedDate: value }),
setCurrentDate: (value) => set({ currentDate: value }),
goToToday: () => {
set({
selectedDate: undefined,
currentDate: new Date(),
});
},
// ===== 뷰 액션 =====
setViewMode: (mode) => set({ viewMode: mode }),
setScrollPosition: (value) => set({ scrollPosition: value }),
// ===== 전체 초기화 =====
reset: () =>
set({
searchInput: '',
searchTerm: '',
isSearchMode: false,
selectedCategories: [],
selectedMembers: [],
selectedDate: undefined,
currentDate: new Date(),
viewMode: 'list',
scrollPosition: 0,
}),
}));
export default useScheduleStore;

View file

@ -1,119 +0,0 @@
/**
* 날짜 관련 유틸리티 함수
* dayjs를 사용하여 KST(한국 표준시) 기준으로 날짜 처리
*/
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { TIMEZONE, WEEKDAYS } from '@/constants';
// 플러그인 확장
dayjs.extend(utc);
dayjs.extend(timezone);
/**
* KST 기준 오늘 날짜 (YYYY-MM-DD)
* @returns {string} 오늘 날짜 문자열
*/
export const getTodayKST = () => {
return dayjs().tz(TIMEZONE).format('YYYY-MM-DD');
};
/**
* 날짜 문자열 포맷팅
* @param {string|Date} date - 날짜
* @param {string} format - 포맷 (기본: 'YYYY-MM-DD')
* @returns {string} 포맷된 날짜 문자열
*/
export const formatDate = (date, format = 'YYYY-MM-DD') => {
if (!date) return '';
return dayjs(date).tz(TIMEZONE).format(format);
};
/**
* 날짜 비교 (같은 날인지)
* @param {string|Date} date1
* @param {string|Date} date2
* @returns {boolean}
*/
export const isSameDay = (date1, date2) => {
return (
dayjs(date1).tz(TIMEZONE).format('YYYY-MM-DD') ===
dayjs(date2).tz(TIMEZONE).format('YYYY-MM-DD')
);
};
/**
* 날짜가 오늘인지 확인
* @param {string|Date} date
* @returns {boolean}
*/
export const isToday = (date) => {
return isSameDay(date, dayjs());
};
/**
* 전체 날짜 포맷 (YYYY. M. D. (요일))
* @param {string|Date} date - 날짜
* @returns {string} 포맷된 문자열
*/
export const formatFullDate = (date) => {
if (!date) return '';
const d = dayjs(date).tz(TIMEZONE);
return `${d.year()}. ${d.month() + 1}. ${d.date()}. (${WEEKDAYS[d.day()]})`;
};
/**
* X(트위터) 스타일 날짜/시간 포맷팅
* @param {string} datetime - datetime 문자열 (YYYY-MM-DDTHH:mm:ss 또는 YYYY-MM-DD)
* @returns {string} "오후 7:00 · 2026년 1월 18일" 또는 "2026년 1월 18일"
*/
export const formatXDateTime = (datetime) => {
if (!datetime) return '';
const d = dayjs(datetime).tz(TIMEZONE);
const datePart = `${d.year()}${d.month() + 1}${d.date()}`;
// datetime에 T가 포함되고 시간이 00:00:00이 아니면 시간 표시
if (datetime.includes('T') && !datetime.endsWith('T00:00:00')) {
const hours = d.hour();
const minutes = d.minute();
// 00:00인 경우 시간 표시 안함
if (hours !== 0 || minutes !== 0) {
const period = hours < 12 ? '오전' : '오후';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
return `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${datePart}`;
}
}
return datePart;
};
/**
* datetime 문자열에서 date 추출
* @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DD"
* @returns {string} "YYYY-MM-DD"
*/
export const extractDate = (datetime) => {
if (!datetime) return '';
return datetime.split(' ')[0].split('T')[0];
};
/**
* datetime 문자열에서 time 추출
* @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DDTHH:mm"
* @returns {string|null} "HH:mm" 또는 null
*/
export const extractTime = (datetime) => {
if (!datetime) return null;
if (datetime.includes(' ')) {
return datetime.split(' ')[1]?.slice(0, 5) || null;
}
if (datetime.includes('T')) {
return datetime.split('T')[1]?.slice(0, 5) || null;
}
return null;
};
// dayjs 인스턴스도 export (고급 사용용)
export { dayjs };

View file

@ -1,21 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
primary: {
DEFAULT: "#548360",
dark: "#456E50",
light: "#6A9A75",
},
secondary: "#F5F5F5",
accent: "#FFD700",
},
fontFamily: {
sans: ["Pretendard", "Inter", "sans-serif"],
},
},
},
plugins: [],
};

View file

@ -1,27 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
host: true,
port: 80,
allowedHosts: true,
proxy: {
"/api": {
target: "http://fromis9-backend:80",
changeOrigin: true,
},
"/docs": {
target: "http://fromis9-backend:80",
changeOrigin: true,
},
},
},
});

View file

@ -1 +0,0 @@
VITE_KAKAO_JS_KEY=84b3c657c3de7d1ca89e1fa33455b8da

View file

@ -2,16 +2,3 @@
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"] CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"]
# 배포 모드 (사용 시 위 개발 모드를 주석처리)
# FROM node:20-alpine AS builder
# WORKDIR /app
# COPY package*.json ./
# RUN npm install
# COPY . .
# RUN npm run build
#
# FROM nginx:alpine
# COPY --from=builder /app/dist /usr/share/nginx/html
# EXPOSE 80
# CMD ["nginx", "-g", "daemon off;"]

File diff suppressed because it is too large Load diff

View file

@ -1,43 +1,44 @@
{ {
"name": "fromis9-frontend", "name": "fromis9-frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "2.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6", "@babel/runtime": "^7.28.6",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"dayjs": "^1.11.19", "clsx": "^2.1.1",
"framer-motion": "^11.0.8", "dayjs": "^1.11.19",
"lucide-react": "^0.344.0", "framer-motion": "^11.0.8",
"react": "^18.2.0", "lucide-react": "^0.344.0",
"react-calendar": "^6.0.0", "react": "^18.2.0",
"react-colorful": "^5.6.1", "react-calendar": "^6.0.0",
"react-device-detect": "^2.2.3", "react-colorful": "^5.6.1",
"react-dom": "^18.2.0", "react-device-detect": "^2.2.3",
"react-infinite-scroll-component": "^6.1.1", "react-dom": "^18.2.0",
"react-intersection-observer": "^10.0.0", "react-infinite-scroll-component": "^6.1.1",
"react-ios-time-picker": "^0.2.2", "react-intersection-observer": "^10.0.0",
"react-linkify": "^1.0.0-alpha", "react-ios-time-picker": "^0.2.2",
"react-photo-album": "^3.4.0", "react-linkify": "^1.0.0-alpha",
"react-router-dom": "^6.22.3", "react-photo-album": "^3.4.0",
"react-window": "^2.2.3", "react-router-dom": "^6.22.3",
"swiper": "^12.0.3", "react-window": "^2.2.3",
"zustand": "^5.0.9" "swiper": "^12.0.3",
}, "zustand": "^5.0.9"
"devDependencies": { },
"@types/react": "^18.3.3", "devDependencies": {
"@types/react-dom": "^18.3.0", "@types/react": "^18.3.3",
"@vitejs/plugin-react": "^4.3.1", "@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.22", "@vitejs/plugin-react": "^4.3.1",
"postcss": "^8.5.6", "autoprefixer": "^10.4.22",
"tailwindcss": "^3.4.18", "postcss": "^8.5.6",
"vite": "^5.4.1" "tailwindcss": "^3.4.18",
} "vite": "^5.4.1"
}
} }

View file

@ -1,123 +1,67 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { BrowserRouter, useLocation } from 'react-router-dom';
import { BrowserView, MobileView } from 'react-device-detect'; import { BrowserView, MobileView } from 'react-device-detect';
// //
import ScrollToTop from './components/ScrollToTop'; import { ScrollToTop } from '@/components/common';
// PC //
import PCHome from './pages/pc/public/Home'; import { PCPublicRoutes, PCAdminRoutes, MobileRoutes } from '@/routes';
import PCMembers from './pages/pc/public/Members';
import PCAlbum from './pages/pc/public/Album';
import PCAlbumDetail from './pages/pc/public/AlbumDetail';
import PCAlbumGallery from './pages/pc/public/AlbumGallery';
import PCTrackDetail from './pages/pc/public/TrackDetail';
import PCSchedule from './pages/pc/public/Schedule';
import PCScheduleDetail from './pages/pc/public/ScheduleDetail';
import PCBirthday from './pages/pc/public/Birthday';
import PCNotFound from './pages/pc/public/NotFound';
// //
import MobileHome from './pages/mobile/public/Home'; import { useAuthStore } from '@/stores';
import MobileMembers from './pages/mobile/public/Members';
import MobileAlbum from './pages/mobile/public/Album';
import MobileAlbumDetail from './pages/mobile/public/AlbumDetail';
import MobileAlbumGallery from './pages/mobile/public/AlbumGallery';
import MobileTrackDetail from './pages/mobile/public/TrackDetail';
import MobileSchedule from './pages/mobile/public/Schedule';
import MobileScheduleDetail from './pages/mobile/public/ScheduleDetail';
import MobileNotFound from './pages/mobile/public/NotFound';
// /**
import AdminLogin from './pages/pc/admin/AdminLogin'; * PC 환경에서 body에 클래스 추가하는 래퍼
import AdminDashboard from './pages/pc/admin/AdminDashboard'; */
import AdminMembers from './pages/pc/admin/AdminMembers';
import AdminMemberEdit from './pages/pc/admin/AdminMemberEdit';
import AdminAlbums from './pages/pc/admin/AdminAlbums';
import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm';
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
import AdminSchedule from './pages/pc/admin/AdminSchedule';
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
import ScheduleFormPage from './pages/pc/admin/schedule/form';
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
import YouTubeEditForm from './pages/pc/admin/schedule/edit/YouTubeEditForm';
//
import PCLayout from './components/pc/Layout';
import MobileLayout from './components/mobile/Layout';
// PC body
function PCWrapper({ children }) { function PCWrapper({ children }) {
useEffect(() => { useEffect(() => {
document.body.classList.add('is-pc'); document.body.classList.add('is-pc');
return () => document.body.classList.remove('is-pc'); return () => document.body.classList.remove('is-pc');
}, []); }, []);
return children; return children;
} }
function App() { /**
return ( * PC 라우트 - admin 경로일 때만 AdminRoutes 렌더링
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}> */
<ScrollToTop /> function PCRoutes() {
<BrowserView> const location = useLocation();
<PCWrapper> const isAdminPath = location.pathname.startsWith('/admin');
<Routes> const { _hasHydrated } = useAuthStore();
{/* 관리자 페이지 (레이아웃 없음) */}
<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={<AdminSchedule />} />
<Route path="/admin/schedule/new" element={<ScheduleFormPage />} />
<Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/:id/edit/youtube" element={<YouTubeEditForm />} />
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
{/* 일반 페이지 (레이아웃 포함) */} // admin hydration
<Route path="/*" element={ if (isAdminPath && !_hasHydrated) {
<PCLayout> return null;
<Routes> }
<Route path="/" element={<PCHome />} />
<Route path="/members" element={<PCMembers />} /> return (
<Route path="/album" element={<PCAlbum />} /> <PCWrapper>
<Route path="/album/:name" element={<PCAlbumDetail />} /> {isAdminPath ? <PCAdminRoutes /> : <PCPublicRoutes />}
<Route path="/album/:name/gallery" element={<PCAlbumGallery />} /> </PCWrapper>
<Route path="/album/:name/track/:trackTitle" element={<PCTrackDetail />} /> );
<Route path="/schedule" element={<PCSchedule />} /> }
<Route path="/schedule/:id" element={<PCScheduleDetail />} />
<Route path="/birthday/:memberName/:year" element={<PCBirthday />} /> /**
<Route path="*" element={<PCNotFound />} /> * 프로미스나인 팬사이트 메인
</Routes> * react-device-detect를 사용한 PC/Mobile 분리
</PCLayout> */
} /> function App() {
</Routes> return (
</PCWrapper> <BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
</BrowserView> <ScrollToTop />
<MobileView>
<Routes> {/* PC 뷰 */}
<Route path="/" element={<MobileLayout><MobileHome /></MobileLayout>} /> <BrowserView>
<Route path="/members" element={<MobileLayout pageTitle="멤버" noShadow><MobileMembers /></MobileLayout>} /> <PCRoutes />
<Route path="/album" element={<MobileLayout pageTitle="앨범"><MobileAlbum /></MobileLayout>} /> </BrowserView>
<Route path="/album/:name" element={<MobileLayout pageTitle="앨범"><MobileAlbumDetail /></MobileLayout>} />
<Route path="/album/:name/gallery" element={<MobileLayout pageTitle="앨범"><MobileAlbumGallery /></MobileLayout>} /> {/* Mobile 뷰 */}
<Route path="/album/:name/track/:trackTitle" element={<MobileLayout pageTitle="앨범"><MobileTrackDetail /></MobileLayout>} /> <MobileView>
<Route path="/schedule" element={<MobileLayout useCustomLayout><MobileSchedule /></MobileLayout>} /> <MobileRoutes />
<Route path="/schedule/:id" element={<MobileScheduleDetail />} /> </MobileView>
<Route path="*" element={<MobileNotFound />} /> </BrowserRouter>
</Routes> );
</MobileView>
</BrowserRouter>
);
} }
export default App; export default App;

View file

@ -1,62 +1,97 @@
/** /**
* 어드민 앨범 관리 API * 관리자 앨범 API
*/ */
import { fetchAdminApi, fetchAdminFormData } from "../index"; import { fetchAuthApi, fetchFormData } from '@/api/client';
// 앨범 목록 조회 /**
* 앨범 목록 조회
* @returns {Promise<Array>}
*/
export async function getAlbums() { export async function getAlbums() {
return fetchAdminApi("/api/albums"); return fetchAuthApi('/albums');
} }
// 앨범 상세 조회 /**
* 앨범 상세 조회
* @param {number} id - 앨범 ID
* @returns {Promise<object>}
*/
export async function getAlbum(id) { export async function getAlbum(id) {
return fetchAdminApi(`/api/albums/${id}`); return fetchAuthApi(`/albums/${id}`);
} }
// 앨범 생성 /**
* 앨범 생성
* @param {FormData} formData - 앨범 데이터
* @returns {Promise<object>}
*/
export async function createAlbum(formData) { export async function createAlbum(formData) {
return fetchAdminFormData("/api/albums", formData, "POST"); return fetchFormData('/albums', formData, 'POST');
} }
// 앨범 수정 /**
* 앨범 수정
* @param {number} id - 앨범 ID
* @param {FormData} formData - 앨범 데이터
* @returns {Promise<object>}
*/
export async function updateAlbum(id, formData) { export async function updateAlbum(id, formData) {
return fetchAdminFormData(`/api/albums/${id}`, formData, "PUT"); return fetchFormData(`/albums/${id}`, formData, 'PUT');
} }
// 앨범 삭제 /**
* 앨범 삭제
* @param {number} id - 앨범 ID
* @returns {Promise<void>}
*/
export async function deleteAlbum(id) { export async function deleteAlbum(id) {
return fetchAdminApi(`/api/albums/${id}`, { method: "DELETE" }); return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' });
} }
// 앨범 사진 목록 조회 /**
* 앨범 사진 목록 조회
* @param {number} albumId - 앨범 ID
* @returns {Promise<Array>}
*/
export async function getAlbumPhotos(albumId) { export async function getAlbumPhotos(albumId) {
return fetchAdminApi(`/api/albums/${albumId}/photos`); return fetchAuthApi(`/albums/${albumId}/photos`);
} }
// 앨범 사진 업로드 /**
* 앨범 사진 업로드
* @param {number} albumId - 앨범 ID
* @param {FormData} formData - 사진 데이터
* @returns {Promise<object>}
*/
export async function uploadAlbumPhotos(albumId, formData) { export async function uploadAlbumPhotos(albumId, formData) {
return fetchAdminFormData( return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST');
`/api/albums/${albumId}/photos`,
formData,
"POST"
);
} }
// 앨범 사진 삭제 /**
* 앨범 사진 삭제
* @param {number} albumId - 앨범 ID
* @param {number} photoId - 사진 ID
* @returns {Promise<void>}
*/
export async function deleteAlbumPhoto(albumId, photoId) { export async function deleteAlbumPhoto(albumId, photoId) {
return fetchAdminApi(`/api/albums/${albumId}/photos/${photoId}`, { return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' });
method: "DELETE",
});
} }
// 앨범 티저 목록 조회 /**
* 앨범 티저 목록 조회
* @param {number} albumId - 앨범 ID
* @returns {Promise<Array>}
*/
export async function getAlbumTeasers(albumId) { export async function getAlbumTeasers(albumId) {
return fetchAdminApi(`/api/albums/${albumId}/teasers`); return fetchAuthApi(`/albums/${albumId}/teasers`);
} }
// 앨범 티저 삭제 /**
* 앨범 티저 삭제
* @param {number} albumId - 앨범 ID
* @param {number} teaserId - 티저 ID
* @returns {Promise<void>}
*/
export async function deleteAlbumTeaser(albumId, teaserId) { export async function deleteAlbumTeaser(albumId, teaserId) {
return fetchAdminApi(`/api/albums/${albumId}/teasers/${teaserId}`, { return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' });
method: "DELETE",
});
} }

View file

@ -1,42 +1,37 @@
/** /**
* 어드민 인증 API * 관리자 인증 API
*/ */
import { fetchAdminApi } from "../index"; import { fetchApi, fetchAuthApi } from '@/api/client';
// 토큰 검증 /**
export async function verifyToken() { * 로그인
return fetchAdminApi("/api/auth/verify"); * @param {string} username - 사용자명
} * @param {string} password - 비밀번호
* @returns {Promise<{token: string, user: object}>}
// 로그인 */
export async function login(username, password) { export async function login(username, password) {
const response = await fetch("/api/auth/login", { return fetchApi('/auth/login', {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "로그인 실패");
}
return response.json();
} }
// 로그아웃 (로컬 스토리지 정리) /**
export function logout() { * 토큰 검증
localStorage.removeItem("adminToken"); * @returns {Promise<{valid: boolean, user: object}>}
localStorage.removeItem("adminUser"); */
export async function verifyToken() {
return fetchAuthApi('/auth/verify');
} }
// 현재 사용자 정보 가져오기 /**
export function getCurrentUser() { * 비밀번호 변경
const userData = localStorage.getItem("adminUser"); * @param {string} currentPassword - 현재 비밀번호
return userData ? JSON.parse(userData) : null; * @param {string} newPassword - 비밀번호
} */
export async function changePassword(currentPassword, newPassword) {
// 토큰 존재 여부 확인 return fetchAuthApi('/auth/change-password', {
export function hasToken() { method: 'POST',
return !!localStorage.getItem("adminToken"); body: JSON.stringify({ currentPassword, newPassword }),
});
} }

View file

@ -1,34 +1,55 @@
/** /**
* 어드민 관리 API * 관리자 관리 API
*/ */
import { fetchAdminApi } from "../index"; import { fetchAuthApi } from '@/api/client';
// 봇 목록 조회 /**
* 목록 조회
* @returns {Promise<Array>}
*/
export async function getBots() { export async function getBots() {
return fetchAdminApi("/api/admin/bots"); return fetchAuthApi('/admin/bots');
} }
// 봇 시작 /**
* 시작
* @param {string} id - ID
* @returns {Promise<object>}
*/
export async function startBot(id) { export async function startBot(id) {
return fetchAdminApi(`/api/admin/bots/${id}/start`, { method: "POST" }); return fetchAuthApi(`/admin/bots/${id}/start`, { method: 'POST' });
} }
// 봇 정지 /**
* 정지
* @param {string} id - ID
* @returns {Promise<object>}
*/
export async function stopBot(id) { export async function stopBot(id) {
return fetchAdminApi(`/api/admin/bots/${id}/stop`, { method: "POST" }); return fetchAuthApi(`/admin/bots/${id}/stop`, { method: 'POST' });
} }
// 봇 전체 동기화 /**
* 전체 동기화
* @param {string} id - ID
* @returns {Promise<object>}
*/
export async function syncAllVideos(id) { export async function syncAllVideos(id) {
return fetchAdminApi(`/api/admin/bots/${id}/sync-all`, { method: "POST" }); return fetchAuthApi(`/admin/bots/${id}/sync-all`, { method: 'POST' });
} }
// 할당량 경고 조회 /**
* 할당량 경고 조회
* @returns {Promise<{warning: boolean, message: string}>}
*/
export async function getQuotaWarning() { export async function getQuotaWarning() {
return fetchAdminApi("/api/admin/bots/quota-warning"); return fetchAuthApi('/admin/bots/quota-warning');
} }
// 할당량 경고 해제 /**
* 할당량 경고 해제
* @returns {Promise<void>}
*/
export async function dismissQuotaWarning() { export async function dismissQuotaWarning() {
return fetchAdminApi("/api/admin/bots/quota-warning", { method: "DELETE" }); return fetchAuthApi('/admin/bots/quota-warning', { method: 'DELETE' });
} }

View file

@ -1,40 +1,60 @@
/** /**
* 어드민 카테고리 API * 관리자 카테고API
*/ */
import { fetchAdminApi } from "../index"; import { fetchAuthApi } from '@/api/client';
// 카테고리 목록 조회 /**
* 카테고리 목록 조회
* @returns {Promise<Array>}
*/
export async function getCategories() { export async function getCategories() {
return fetchAdminApi("/api/schedules/categories"); return fetchAuthApi('/schedules/categories');
} }
// 카테고리 생성 /**
* 카테고리 생성
* @param {object} data - 카테고리 데이터
* @param {string} data.name - 카테고리 이름
* @param {string} data.color - 색상 코드
* @returns {Promise<object>}
*/
export async function createCategory(data) { export async function createCategory(data) {
return fetchAdminApi("/api/admin/schedule-categories", { return fetchAuthApi('/admin/schedule-categories', {
method: "POST", method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
// 카테고리 수정 /**
* 카테고리 수정
* @param {number} id - 카테고리 ID
* @param {object} data - 카테고리 데이터
* @returns {Promise<object>}
*/
export async function updateCategory(id, data) { export async function updateCategory(id, data) {
return fetchAdminApi(`/api/admin/schedule-categories/${id}`, { return fetchAuthApi(`/admin/schedule-categories/${id}`, {
method: "PUT", method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
// 카테고리 삭제 /**
* 카테고리 삭제
* @param {number} id - 카테고리 ID
* @returns {Promise<void>}
*/
export async function deleteCategory(id) { export async function deleteCategory(id) {
return fetchAdminApi(`/api/admin/schedule-categories/${id}`, { return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' });
method: "DELETE",
});
} }
// 카테고리 순서 변경 /**
* 카테고리 순서 변경
* @param {Array<{id: number, sort_order: number}>} orders - 순서 데이터
* @returns {Promise<void>}
*/
export async function reorderCategories(orders) { export async function reorderCategories(orders) {
return fetchAdminApi("/api/admin/schedule-categories-order", { return fetchAuthApi('/admin/schedule-categories-order', {
method: "PUT", method: 'PUT',
body: JSON.stringify({ orders }), body: JSON.stringify({ orders }),
}); });
} }

View file

@ -1,19 +1,31 @@
/** /**
* 어드민 멤버 관리 API * 관리자 멤버 API
*/ */
import { fetchAdminApi, fetchAdminFormData } from "../index"; import { fetchAuthApi, fetchFormData } from '@/api/client';
// 멤버 목록 조회 /**
* 멤버 목록 조회
* @returns {Promise<Array>}
*/
export async function getMembers() { export async function getMembers() {
return fetchAdminApi("/api/members"); return fetchAuthApi('/members');
} }
// 멤버 상세 조회 /**
* 멤버 상세 조회
* @param {number} id - 멤버 ID
* @returns {Promise<object>}
*/
export async function getMember(id) { export async function getMember(id) {
return fetchAdminApi(`/api/members/${id}`); return fetchAuthApi(`/members/${id}`);
} }
// 멤버 수정 /**
* 멤버 수정
* @param {number} id - 멤버 ID
* @param {FormData} formData - 멤버 데이터
* @returns {Promise<object>}
*/
export async function updateMember(id, formData) { export async function updateMember(id, formData) {
return fetchAdminFormData(`/api/members/${id}`, formData, "PUT"); return fetchFormData(`/members/${id}`, formData, 'PUT');
} }

View file

@ -1,35 +1,21 @@
/** /**
* 어드민 일정 관리 API * 관리자 일정 API
*/ */
import { fetchAdminApi, fetchAdminFormData } from "../index"; import { fetchAuthApi, fetchFormData } from '@/api/client';
/** /**
* API 응답을 프론트엔드 형식으로 변환 * API 응답을 프론트엔드 형식으로 변환
* - datetime date, time 분리
* - category 객체 category_id, category_name, category_color 플랫화 * - category 객체 category_id, category_name, category_color 플랫화
* - members 배열 member_names 문자열 * - members 배열 member_names 문자열
*/ */
function transformSchedule(schedule) { function transformSchedule(schedule) {
const category = schedule.category || {}; 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 배열을 문자열로 (기존 코드 호환성) // members 배열을 문자열로 (기존 코드 호환성)
const memberNames = Array.isArray(schedule.members) const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') : '';
? schedule.members.join(',')
: '';
return { return {
...schedule, ...schedule,
date,
time,
category_id: category.id, category_id: category.id,
category_name: category.name, category_name: category.name,
category_color: category.color, category_color: category.color,
@ -37,16 +23,28 @@ function transformSchedule(schedule) {
}; };
} }
// 일정 목록 조회 (월별) /**
* 일정 목록 조회 (월별)
* @param {number} year - 년도
* @param {number} month -
* @returns {Promise<Array>}
*/
export async function getSchedules(year, month) { export async function getSchedules(year, month) {
const data = await fetchAdminApi(`/api/schedules?year=${year}&month=${month}`); const data = await fetchAuthApi(`/schedules?year=${year}&month=${month}`);
return (data.schedules || []).map(transformSchedule); return (data.schedules || []).map(transformSchedule);
} }
// 일정 검색 (Meilisearch) /**
* 일정 검색 (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 } = {}) { export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
const data = await fetchAdminApi( const data = await fetchAuthApi(
`/api/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}` `/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
); );
return { return {
...data, ...data,
@ -54,22 +52,39 @@ export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
}; };
} }
// 일정 상세 조회 /**
* 일정 상세 조회
* @param {number} id - 일정 ID
* @returns {Promise<object>}
*/
export async function getSchedule(id) { export async function getSchedule(id) {
return fetchAdminApi(`/api/admin/schedules/${id}`); return fetchAuthApi(`/schedules/${id}`);
} }
// 일정 생성 /**
* 일정 생성
* @param {FormData} formData - 일정 데이터
* @returns {Promise<object>}
*/
export async function createSchedule(formData) { export async function createSchedule(formData) {
return fetchAdminFormData("/api/admin/schedules", formData, "POST"); return fetchFormData('/admin/schedules', formData, 'POST');
} }
// 일정 수정 /**
* 일정 수정
* @param {number} id - 일정 ID
* @param {FormData} formData - 일정 데이터
* @returns {Promise<object>}
*/
export async function updateSchedule(id, formData) { export async function updateSchedule(id, formData) {
return fetchAdminFormData(`/api/admin/schedules/${id}`, formData, "PUT"); return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT');
} }
// 일정 삭제 /**
* 일정 삭제
* @param {number} id - 일정 ID
* @returns {Promise<void>}
*/
export async function deleteSchedule(id) { export async function deleteSchedule(id) {
return fetchAdminApi(`/api/schedules/${id}`, { method: "DELETE" }); return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' });
} }

View file

@ -1,9 +1,12 @@
/** /**
* 어드민 통계 API * 관리자 통계 API
*/ */
import { fetchAdminApi } from "../index"; import { fetchAuthApi } from '@/api/client';
// 대시보드 통계 조회 /**
* 대시보드 통계 조회
* @returns {Promise<object>}
*/
export async function getStats() { export async function getStats() {
return fetchAdminApi("/api/stats"); return fetchAuthApi('/stats');
} }

View file

@ -1,17 +1,24 @@
/** /**
* 어드민 추천 검색어 API * 관리자 추천 검색어 API
*/ */
import { fetchAdminApi } from "../index"; import { fetchAuthApi } from '@/api/client';
// 사전 내용 조회 /**
* 사전 내용 조회
* @returns {Promise<{content: string}>}
*/
export async function getDict() { export async function getDict() {
return fetchAdminApi("/api/schedules/suggestions/dict"); return fetchAuthApi('/schedules/suggestions/dict');
} }
// 사전 저장 /**
* 사전 저장
* @param {string} content - 사전 내용
* @returns {Promise<void>}
*/
export async function saveDict(content) { export async function saveDict(content) {
return fetchAdminApi("/api/schedules/suggestions/dict", { return fetchAuthApi('/schedules/suggestions/dict', {
method: "PUT", method: 'PUT',
body: JSON.stringify({ content }), body: JSON.stringify({ content }),
}); });
} }

View file

@ -143,13 +143,3 @@ export const api = createMethodHelpers(fetchApi);
* @example authApi.get('/admin/stats'), authApi.post('/admin/schedules', data) * @example authApi.get('/admin/stats'), authApi.post('/admin/schedules', data)
*/ */
export const authApi = createMethodHelpers(fetchAuthApi); 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;

View file

@ -1,64 +1,16 @@
/** /**
* 공통 API 유틸리티 * API 통합 export
* 모든 API 호출에서 사용되는 기본 fetch 래퍼
*/ */
// 기본 fetch 래퍼 // 공통 유틸리티
export async function fetchApi(url, options = {}) { export * from './client';
const headers = { ...options.headers };
// body가 있을 때만 Content-Type 설정 (DELETE 등 body 없는 요청 대응) // 공개 API
if (options.body) { export * from './public';
headers["Content-Type"] = "application/json"; export * as scheduleApi from './public/schedules';
} export * as albumApi from './public/albums';
export * as memberApi from './public/members';
const response = await fetch(url, { // 관리자 API
...options, export * from './admin';
headers, export * as authApi from './admin/auth';
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "요청 실패" }));
throw new Error(error.error || `HTTP ${response.status}`);
}
return response.json();
}
// 어드민 토큰 가져오기
export function getAdminToken() {
return localStorage.getItem("adminToken");
}
// 어드민 API용 fetch 래퍼 (토큰 자동 추가)
export async function fetchAdminApi(url, options = {}) {
const token = getAdminToken();
return fetchApi(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
}
// FormData 전송용 (이미지 업로드 등)
export async function fetchAdminFormData(url, formData, method = "POST") {
const token = getAdminToken();
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "요청 실패" }));
throw new Error(error.error || `HTTP ${response.status}`);
}
return response.json();
}

View file

@ -1,38 +1,101 @@
/** /**
* 앨범 관련 공개 API * 앨범 API
*/ */
import { fetchApi } from "../index"; import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
// 앨범 목록 조회 // ==================== 공개 API ====================
/**
* 앨범 목록 조회
*/
export async function getAlbums() { export async function getAlbums() {
return fetchApi("/api/albums"); return fetchApi('/albums');
} }
// 앨범 상세 조회 (ID) /**
* 앨범 상세 조회 (ID)
*/
export async function getAlbum(id) { export async function getAlbum(id) {
return fetchApi(`/api/albums/${id}`); return fetchApi(`/albums/${id}`);
} }
// 앨범 상세 조회 (이름) /**
* 앨범 상세 조회 (이름)
*/
export async function getAlbumByName(name) { export async function getAlbumByName(name) {
return fetchApi(`/api/albums/by-name/${name}`); return fetchApi(`/albums/by-name/${encodeURIComponent(name)}`);
} }
// 앨범 사진 조회 /**
* 앨범 사진 조회
*/
export async function getAlbumPhotos(albumId) { export async function getAlbumPhotos(albumId) {
return fetchApi(`/api/albums/${albumId}/photos`); return fetchApi(`/albums/${albumId}/photos`);
} }
// 앨범 트랙 조회 /**
* 앨범 트랙 조회
*/
export async function getAlbumTracks(albumId) { export async function getAlbumTracks(albumId) {
return fetchApi(`/api/albums/${albumId}/tracks`); return fetchApi(`/albums/${albumId}/tracks`);
} }
// 트랙 상세 조회 (앨범명, 트랙명으로) /**
* 트랙 상세 조회 (앨범명, 트랙명으로)
*/
export async function getTrack(albumName, trackTitle) { export async function getTrack(albumName, trackTitle) {
return fetchApi( return fetchApi(
`/api/albums/by-name/${encodeURIComponent( `/albums/by-name/${encodeURIComponent(albumName)}/track/${encodeURIComponent(trackTitle)}`
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' });
}

View file

@ -1,14 +1,43 @@
/** /**
* 멤버 관련 공개 API * 멤버 API
*/ */
import { fetchApi } from "../index"; import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
// 멤버 목록 조회 // ==================== 공개 API ====================
/**
* 멤버 목록 조회
*/
export async function getMembers() { export async function getMembers() {
return fetchApi("/api/members"); return fetchApi('/members');
} }
// 멤버 상세 조회 /**
* 멤버 상세 조회
*/
export async function getMember(id) { export async function getMember(id) {
return fetchApi(`/api/members/${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' });
} }

View file

@ -1,27 +1,17 @@
/** /**
* 일정 관련 공개 API * 스케줄 API
*/ */
import { fetchApi } from "../index"; import { fetchApi, fetchAuthApi, fetchFormData } from '@/api/client';
import { getTodayKST } from "../../utils/date"; import { getTodayKST } from '@/utils';
/** /**
* API 응답을 프론트엔드 형식으로 변환 * API 응답을 프론트엔드 형식으로 변환
* - datetime date, time 분리
* - category 객체 category_id, category_name, category_color 플랫화 * - category 객체 category_id, category_name, category_color 플랫화
* - members 배열 member_names 문자열 * - members 배열 member_names 문자열
*/ */
function transformSchedule(schedule) { function transformSchedule(schedule) {
const category = schedule.category || {}; 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 배열을 문자열로 (기존 코드 호환성) // members 배열을 문자열로 (기존 코드 호환성)
const memberNames = Array.isArray(schedule.members) const memberNames = Array.isArray(schedule.members)
? schedule.members.join(',') ? schedule.members.join(',')
@ -29,8 +19,6 @@ function transformSchedule(schedule) {
return { return {
...schedule, ...schedule,
date,
time,
category_id: category.id, category_id: category.id,
category_name: category.name, category_name: category.name,
category_color: category.color, category_color: category.color,
@ -38,23 +26,31 @@ function transformSchedule(schedule) {
}; };
} }
// 일정 목록 조회 (월별) // ==================== 공개 API ====================
/**
* 스케줄 목록 조회 (월별)
*/
export async function getSchedules(year, month) { export async function getSchedules(year, month) {
const data = await fetchApi(`/api/schedules?year=${year}&month=${month}`); const data = await fetchApi(`/schedules?year=${year}&month=${month}`);
return (data.schedules || []).map(transformSchedule); return (data.schedules || []).map(transformSchedule);
} }
// 다가오는 일정 조회 (오늘 이후) /**
* 다가오는 스케줄 조회
*/
export async function getUpcomingSchedules(limit = 3) { export async function getUpcomingSchedules(limit = 3) {
const todayStr = getTodayKST(); const today = getTodayKST();
const data = await fetchApi(`/api/schedules?startDate=${todayStr}&limit=${limit}`); const data = await fetchApi(`/schedules?startDate=${today}&limit=${limit}`);
return (data.schedules || []).map(transformSchedule); return (data.schedules || []).map(transformSchedule);
} }
// 일정 검색 (Meilisearch) /**
* 스케줄 검색 (Meilisearch)
*/
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) { export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
const data = await fetchApi( const data = await fetchApi(
`/api/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}` `/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
); );
return { return {
...data, ...data,
@ -62,12 +58,99 @@ export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
}; };
} }
// 일정 상세 조회 /**
* 스케줄 상세 조회
*/
export async function getSchedule(id) { export async function getSchedule(id) {
return fetchApi(`/api/schedules/${id}`); return fetchApi(`/schedules/${id}`);
} }
// X 프로필 정보 조회 /**
* X 프로필 정보 조회
*/
export async function getXProfile(username) { export async function getXProfile(username) {
return fetchApi(`/api/schedules/x-profile/${encodeURIComponent(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 }),
});
} }

View file

@ -1,22 +0,0 @@
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;

View file

@ -1,32 +0,0 @@
import { motion, AnimatePresence } from 'framer-motion';
/**
* Toast 컴포넌트 (Minecraft Web 스타일)
* - 하단 중앙에 표시
* - type: 'success' | 'error' | 'warning'
*/
function Toast({ toast, onClose }) {
return (
<AnimatePresence>
{toast && (
<motion.div
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;

View file

@ -1,70 +0,0 @@
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과 호환)
*/
const 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;

View file

@ -1,46 +0,0 @@
/**
* AdminHeader 컴포넌트
* 모든 Admin 페이지에서 공통으로 사용하는 헤더
* 로고, Admin 배지, 사용자 정보, 로그아웃 버튼 포함
*/
import { useNavigate, Link } from 'react-router-dom';
import { LogOut } from 'lucide-react';
function AdminHeader({ user }) {
const navigate = useNavigate();
const handleLogout = () => {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminUser');
navigate('/admin');
};
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;

View file

@ -1,25 +0,0 @@
/**
* AdminLayout 컴포넌트
* 모든 Admin 페이지에서 공통으로 사용하는 레이아웃
* 헤더 고정 + 본문 스크롤 구조
*/
import { useLocation } from 'react-router-dom';
import AdminHeader from './AdminHeader';
function AdminLayout({ user, children }) {
const location = useLocation();
//
const isSchedulePage = location.pathname.includes('/admin/schedules');
return (
<div className="h-screen overflow-hidden flex flex-col bg-gray-50">
<AdminHeader user={user} />
<main className={`flex-1 min-h-0 ${isSchedulePage ? 'overflow-hidden' : 'overflow-y-auto'}`}>
{children}
</main>
</div>
);
}
export default AdminLayout;

View file

@ -1,115 +0,0 @@
/**
* 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;

View file

@ -1,263 +0,0 @@
/**
* 커스텀 데이트픽커 컴포넌트
* // 선택이 가능한 드롭다운 형태의 날짜 선택기
*/
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Calendar, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfWeek = false }) {
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);
}, []);
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);
}
const MIN_YEAR = 2025;
const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1);
const years = Array.from({ length: 12 }, (_, i) => startYear + i);
const canGoPrevYearRange = startYear > MIN_YEAR;
const prevMonth = () => setViewDate(new Date(year, month - 1, 1));
const nextMonth = () => setViewDate(new Date(year, month + 1, 1));
const prevYearRange = () => canGoPrevYearRange && setViewDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1));
const nextYearRange = () => setViewDate(new Date(year + 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 isCurrentYear = (y) => new Date().getFullYear() === y;
const isCurrentMonth = (m) => {
const today = new Date();
return today.getFullYear() === year && today.getMonth() === m;
};
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
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'}>
{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={viewMode === 'years' ? prevYearRange : prevMonth}
disabled={viewMode === 'years' && !canGoPrevYearRange}
className={`p-1.5 rounded-lg transition-colors ${viewMode === 'years' && !canGoPrevYearRange ? 'opacity-30' : 'hover:bg-gray-100'}`}
>
<ChevronLeft size={20} className="text-gray-600" />
</button>
<button
type="button"
onClick={() => 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={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={() => selectYear(y)}
className={`py-2 rounded-lg text-sm transition-colors ${year === y ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentYear(y) && year !== y ? 'text-primary font-medium' : ''}`}
>
{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={() => selectMonth(i)}
className={`py-2 rounded-lg text-sm transition-colors ${month === i ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentMonth(i) && month !== i ? 'text-primary font-medium' : ''}`}
>
{m}
</button>
))}
</div>
</motion.div>
)}
{viewMode === 'months' && (
<motion.div
key="months"
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">
{monthNames.map((m, i) => (
<button
key={m}
type="button"
onClick={() => selectMonth(i)}
className={`py-2.5 rounded-lg text-sm transition-colors ${month === i ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentMonth(i) && month !== i ? 'text-primary font-medium' : ''}`}
>
{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={() => day && 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 CustomDatePicker;

View file

@ -1,178 +0,0 @@
/**
* CustomTimePicker 컴포넌트
* 오전/오후, 시간, 분을 선택할 있는 시간 피커
* NumberPicker를 사용하여 스크롤 방식 선택 제공
*/
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Clock } from 'lucide-react';
import NumberPicker from './NumberPicker';
function CustomTimePicker({ 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 CustomTimePicker;

View file

@ -1,192 +0,0 @@
/**
* 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;

View file

@ -1,196 +1,290 @@
import { useState, useEffect, useCallback, memo } from 'react'; import { useState, useEffect, useCallback, memo } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight } from 'lucide-react'; import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import LightboxIndicator from './LightboxIndicator';
// - CSS transition GPU /**
const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, goToIndex }) { * 라이트박스 공통 컴포넌트
const translateX = -(currentIndex * 18) + 100 - 6; * 이미지/비디오 갤러리를 전체 화면으로 표시
*
* @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);
return ( // /
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden" style={{ width: '200px' }}> const goToPrev = useCallback(() => {
{/* 양옆 페이드 그라데이션 */} if (images.length <= 1) return;
<div className="absolute inset-0 pointer-events-none z-10" style={{ setImageLoaded(false);
background: 'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)' setSlideDirection(-1);
}} /> onIndexChange((currentIndex - 1 + images.length) % images.length);
{/* 슬라이딩 컨테이너 */} }, [images.length, currentIndex, onIndexChange]);
<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}
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>
);
});
// const goToNext = useCallback(() => {
function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) { if (images.length <= 1) return;
const [imageLoaded, setImageLoaded] = useState(false); setImageLoaded(false);
const [slideDirection, setSlideDirection] = useState(0); setSlideDirection(1);
onIndexChange((currentIndex + 1) % images.length);
}, [images.length, currentIndex, onIndexChange]);
// / const goToIndex = useCallback(
const goToPrev = useCallback(() => { (index) => {
if (images.length <= 1) return; if (index === currentIndex) return;
setImageLoaded(false); setImageLoaded(false);
setSlideDirection(-1); setSlideDirection(index > currentIndex ? 1 : -1);
onIndexChange((currentIndex - 1 + images.length) % images.length); onIndexChange(index);
}, [images.length, currentIndex, onIndexChange]); },
[currentIndex, onIndexChange]
);
const goToNext = useCallback(() => { //
if (images.length <= 1) return; const downloadImage = useCallback(async () => {
setImageLoaded(false); const imageUrl = images[currentIndex];
setSlideDirection(1); if (!imageUrl) return;
onIndexChange((currentIndex + 1) % images.length);
}, [images.length, currentIndex, onIndexChange]);
const goToIndex = useCallback((index) => { try {
if (index === currentIndex) return; const response = await fetch(imageUrl);
setImageLoaded(false); const blob = await response.blob();
setSlideDirection(index > currentIndex ? 1 : -1); const url = URL.createObjectURL(blob);
onIndexChange(index); const link = document.createElement('a');
}, [currentIndex, onIndexChange]); 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 // body
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.documentElement.style.overflow = 'hidden'; document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} else { } else {
document.documentElement.style.overflow = ''; document.documentElement.style.overflow = '';
document.body.style.overflow = ''; document.body.style.overflow = '';
} }
return () => { return () => {
document.documentElement.style.overflow = ''; document.documentElement.style.overflow = '';
document.body.style.overflow = ''; document.body.style.overflow = '';
}; };
}, [isOpen]); }, [isOpen]);
// //
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
switch (e.key) { switch (e.key) {
case 'ArrowLeft': case 'ArrowLeft':
goToPrev(); goToPrev();
break; break;
case 'ArrowRight': case 'ArrowRight':
goToNext(); goToNext();
break; break;
case 'Escape': case 'Escape':
onClose(); onClose();
break; break;
default: default:
break; break;
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, goToPrev, goToNext, onClose]); }, [isOpen, goToPrev, goToNext, onClose]);
// //
useEffect(() => { useEffect(() => {
setImageLoaded(false); setImageLoaded(false);
}, [currentIndex]); }, [currentIndex]);
return ( //
<AnimatePresence> const currentPhoto = photos?.[currentIndex];
{isOpen && images.length > 0 && ( const photoTitle = currentPhoto?.title;
<motion.div const hasValidTitle = photoTitle && photoTitle.trim() && photoTitle !== 'Default';
initial={{ opacity: 0 }} const photoMembers = currentPhoto?.members;
animate={{ opacity: 1 }} const hasMembers = photoMembers && String(photoMembers).trim();
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-[800px] min-h-[600px] w-full h-full relative flex items-center justify-center">
{/* 닫기 버튼 */}
<button
className="absolute top-6 right-6 text-white/70 hover:text-white transition-colors z-10"
onClick={onClose}
>
<X size={32} />
</button>
{/* 이전 버튼 */} return (
{images.length > 1 && ( <AnimatePresence>
<button {isOpen && images.length > 0 && (
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10" <motion.div
onClick={(e) => { role="dialog"
e.stopPropagation(); aria-modal="true"
goToPrev(); aria-label="이미지 뷰어"
}} initial={{ opacity: 0 }}
> animate={{ opacity: 1 }}
<ChevronLeft size={48} /> exit={{ opacity: 0 }}
</button> transition={{ duration: 0.2 }}
)} className="fixed inset-0 bg-black/95 z-50 overflow-scroll"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
{/* 로딩 스피너 */} onClick={onClose}
{!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 className="min-w-[1400px] min-h-[1200px] w-full h-full relative flex items-center justify-center">
</div> {/* 카운터 */}
)} {showCounter && images.length > 1 && (
<div className="absolute top-6 left-6 text-white/70 text-sm z-10">
{/* 이미지 */} {currentIndex + 1} / {images.length}
<div className="flex flex-col items-center mx-24"> </div>
<motion.img
key={currentIndex}
src={images[currentIndex]}
alt={`이미지 ${currentIndex + 1}`}
className={`max-w-[90vw] max-h-[85vh] 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' }}
/>
</div>
{/* 다음 버튼 */}
{images.length > 1 && (
<button
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
>
<ChevronRight size={48} />
</button>
)}
{/* 인디케이터 */}
{images.length > 1 && (
<LightboxIndicator
count={images.length}
currentIndex={currentIndex}
goToIndex={goToIndex}
/>
)}
</div>
</motion.div>
)} )}
</AnimatePresence>
); {/* 상단 버튼들 */}
<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; export default Lightbox;

View file

@ -5,39 +5,53 @@ import { memo } from 'react';
* 이미지 갤러리에서 현재 위치를 표시하는 슬라이딩 인디케이터 * 이미지 갤러리에서 현재 위치를 표시하는 슬라이딩 인디케이터
* CSS transition 사용으로 GPU 가속 * CSS transition 사용으로 GPU 가속
*/ */
const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, goToIndex, width = 200 }) { const LightboxIndicator = memo(function LightboxIndicator({
const halfWidth = width / 2; count,
const translateX = -(currentIndex * 18) + halfWidth - 6; currentIndex,
goToIndex,
width = 200,
}) {
const halfWidth = width / 2;
const translateX = -(currentIndex * 18) + halfWidth - 6;
return ( return (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden" style={{ width: `${width}px` }}> <div
{/* 양옆 페이드 그라데이션 */} className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden"
<div className="absolute inset-0 pointer-events-none z-10" style={{ style={{ width: `${width}px` }}
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
<div className="absolute inset-0 pointer-events-none z-10"
className="flex items-center gap-2 justify-center" style={{
style={{ background:
width: `${count * 18}px`, 'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)',
transform: `translateX(${translateX}px)`, }}
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)' />
}} {/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
> <div
{Array.from({ length: count }).map((_, i) => ( className="flex items-center gap-2 justify-center"
<button style={{
key={i} width: `${count * 18}px`,
className={`rounded-full flex-shrink-0 transition-all duration-300 ${ transform: `translateX(${translateX}px)`,
i === currentIndex transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
? 'w-3 h-3 bg-white' }}
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60' >
}`} {Array.from({ length: count }).map((_, i) => (
onClick={() => goToIndex(i)} <button
/> key={i}
))} aria-label={`이미지 ${i + 1}/${count}`}
</div> aria-current={i === currentIndex ? 'true' : undefined}
</div> 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; export default LightboxIndicator;

Some files were not shown because too many files have changed in this diff Show more