Compare commits

..

17 commits

Author SHA1 Message Date
2d469739b7 fix: 봇 상태 표시 및 일정 추가 애니메이션 개선
- 봇 lastAddedCount를 실제 추가시에만 업데이트 (0으로 덮어쓰지 않음)
- 일정 추가 폼 애니메이션 타이밍 개선 (첫 로딩: 딜레이, 카테고리 변경: 즉시)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 21:31:43 +09:00
1a9fa54981 fix: 생일 표시 버그 수정 및 데이트픽커 개선
- 생일이 생년 이전 년도에 표시되는 버그 수정
- 페이지 진입 애니메이션 추가 (사전 관리, 일정 추가)
- 데이트픽커 12년 단위 이동으로 변경
- 년도 선택 시 월 선택 화면 전환 제거
- 시작 년도 2025년 고정, 이전 이동 비활성화
- PC/모바일 일정 페이지, 관리자 페이지 모두 적용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 16:41:34 +09:00
2576a244c0 feat: 사전 관리, 일정 추가 페이지에 애니메이션 추가
- framer-motion을 사용한 페이지 진입 애니메이션
- 섹션별 stagger 애니메이션으로 순차적 등장 효과
- 카테고리 전환 시 폼 fade 애니메이션

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 16:01:54 +09:00
84ed48fa78 fix: X 폼 Nitter 파싱 수정 및 아이콘 변경
- scraper.js: main-tweet 파싱 정규식 수정 (id 선택자 사용)
- XForm.jsx: Twitter 아이콘을 X 로고로 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:59:26 +09:00
bc3f536ec7 feat: X 카테고리 일정 추가 폼 구현
- 백엔드: /api/admin/x/post-info, /api/admin/x/schedule API 추가
- scraper.js에 fetchSingleTweet 함수 추가 (Nitter로 단일 트윗 조회)
- 프론트엔드: XForm 컴포넌트 생성 (게시글 ID 입력 → 미리보기 → 저장)
- 일정 추가 폼에서 X 카테고리 분기 추가
- API 문서 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 12:57:06 +09:00
0a73149849 feat: 일정 추가 페이지 카테고리별 폼 분리 (YouTube)
- 카테고리 선택 UI를 최상단으로 이동
- YouTube 카테고리 전용 폼 추가 (URL 입력 → 자동 정보 조회)
- 폴더 구조 분리: pages/pc/admin/schedule/form/
- API 추가:
  - GET /schedules/categories (카테고리 목록)
  - DELETE /schedules/:id (일정 삭제)
  - GET /admin/youtube/video-info (영상 정보 조회)
  - POST /admin/youtube/schedule (YouTube 일정 저장)
- fetchApi에서 body 없는 요청 시 Content-Type 미설정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 12:49:29 +09:00
c7b0a51924 feat: YouTube API 할당량 절감을 위한 playlist ID 캐싱
- uploads playlist ID를 Redis에 영구 캐싱 (불변값)
- 일일 API 사용량 6,480 → 4,320 units (33% 절감)
- 문서 업데이트 (컨테이너 분리 구조, X source.name 빈 문자열)
- CLAUDE.md에 문서 업데이트 필수 안내 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 12:32:04 +09:00
149e85ebd9 feat: X 봇 프로필 정보 DB 저장 기능 추가
- x_profiles 테이블 생성 (username, display_name, avatar_url)
- saveProfile 함수로 DB + Redis 캐시 동시 저장
- getProfile 함수 Redis → DB 폴백 지원

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 10:19:32 +09:00
c5f5639b11 fix: 모바일 일정 페이지 카테고리 API 형식 수정 및 X source.name 비움
- 모바일 Schedule.jsx: category 객체 형식 지원 (category.id, category.name 등)
- 백엔드 API: X 일정의 source.name을 빈 문자열로 변경
- Meilisearch: 검색 결과도 source 객체 형식으로 통일

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 10:16:24 +09:00
d7d0506b83 docs: named volume 제거 반영
- node_modules 호스트 설치 설명 추가
- 완전 재시작 명령어에서 -v 플래그 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 09:59:51 +09:00
e0f328803e chore: 불필요한 named volume 제거
- frontend_modules, backend_modules 볼륨 제거
- 컨테이너에서 npm install 시 호스트 node_modules에 직접 설치

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 09:58:46 +09:00
a9bdbd1ec2 chore: docker-compose.yml 불필요한 주석 제거
- 배포 모드 관련 주석 제거 (Dockerfile에서 처리)
- VITE_API_URL 환경변수 제거 (Vite 프록시 사용)
- 문서 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 09:56:20 +09:00
7e9e51666a fix: 컨테이너 분리 후 프록시 설정 수정
- vite.config.js: localhost:3000 → fromis9-backend:80
- docker-compose.yml: depends_on, VITE_API_URL 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 09:54:07 +09:00
8a8af275a9 docs: 컨테이너 분리 반영하여 개발 가이드 업데이트
- 컨테이너 구성 표 추가
- 개별 컨테이너 로그/재시작 명령어 추가
- 배포 모드 Dockerfile 예시 분리
- 네트워크 구조 다이어그램 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 09:51:26 +09:00
b824c38815 refactor: 프론트엔드/백엔드 컨테이너 분리
- backend/Dockerfile 생성
- frontend/Dockerfile 생성
- docker-compose.yml에 별도 서비스로 분리
- 기존 루트 Dockerfile 삭제

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 09:49:27 +09:00
72db9dcdc1 fix: 추천 검색어 동적 임계값 필터링 복원
- SQL GREATEST()로 동적 임계값 적용
- MAX(count) * 1% 또는 최소 10회 중 더 큰 값 사용
- Prefix, 초성, 인기 검색어 모두 필터링 적용
- 데이터가 적을 때도 오타 필터링 가능

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 09:46:44 +09:00
108265b1fd docs: 봇 관리 API 문서 추가
- GET /admin/bots: 봇 목록 조회
- POST /admin/bots/:id/start: 봇 시작
- POST /admin/bots/:id/stop: 봇 정지
- POST /admin/bots/:id/sync-all: 전체 동기화
- GET/DELETE /admin/bots/quota-warning: 할당량 경고

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 23:48:21 +09:00
33 changed files with 1869 additions and 195 deletions

View file

@ -29,3 +29,7 @@ DB 및 외부 서비스 접근 정보는 `.env` 파일 참조:
- [docs/architecture.md](docs/architecture.md) - 프로젝트 구조
- [docs/api.md](docs/api.md) - API 명세
- [docs/development.md](docs/development.md) - 개발/배포 가이드
## 작업 시 주의사항
- **문서 업데이트 필수**: 작업이 완료되면 항상 `docs/` 폴더의 관련 문서를 업데이트할 것

View file

@ -1,27 +0,0 @@
# ============================================
# 개발 모드
# ============================================
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg
CMD ["sh", "-c", "cd /app/backend && npm install && cd /app/frontend && npm install --include=dev && (cd /app/backend && PORT=3000 npm run dev &) && sleep 3 && cd /app/frontend && npm run dev -- --host 0.0.0.0"]
# ============================================
# 배포 모드 (사용 시 위 개발 모드를 주석처리)
# ============================================
# FROM node:20-alpine AS frontend-builder
# WORKDIR /frontend
# COPY frontend/package*.json ./
# RUN npm install
# COPY frontend/ ./
# RUN npm run build
#
# FROM node:20-alpine
# WORKDIR /app
# RUN apk add --no-cache ffmpeg
# COPY backend/package*.json ./
# RUN npm install --production
# COPY backend/ ./
# COPY --from=frontend-builder /frontend/dist ./dist
# EXPOSE 80
# CMD ["npm", "start"]

15
backend/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
# 개발 모드
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg
CMD ["sh", "-c", "npm install && npm run dev"]
# 배포 모드 (사용 시 위 개발 모드를 주석처리)
# FROM node:20-alpine
# WORKDIR /app
# RUN apk add --no-cache ffmpeg
# COPY package*.json ./
# RUN npm install --production
# COPY . .
# EXPOSE 3000
# CMD ["npm", "start"]

View file

@ -0,0 +1,10 @@
-- X 프로필 테이블
CREATE TABLE IF NOT EXISTS x_profiles (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(100),
avatar_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View file

@ -72,13 +72,17 @@ async function schedulerPlugin(fastify, opts) {
try {
const result = await syncFn(bot);
const status = await getStatus(botId);
await updateStatus(botId, {
const updateData = {
status: 'running',
lastCheckAt: new Date().toISOString(),
lastAddedCount: result.addedCount,
totalAdded: (status.totalAdded || 0) + result.addedCount,
errorMessage: null,
});
};
// 실제로 추가된 경우에만 lastAddedCount 업데이트
if (result.addedCount > 0) {
updateData.lastAddedCount = result.addedCount;
}
await updateStatus(botId, updateData);
fastify.log.info(`[${botId}] 동기화 완료: ${result.addedCount}개 추가`);
} catch (err) {
await updateStatus(botId, {
@ -98,11 +102,15 @@ async function schedulerPlugin(fastify, opts) {
try {
const result = await syncFn(bot);
const status = await getStatus(botId);
await updateStatus(botId, {
const updateData = {
lastCheckAt: new Date().toISOString(),
lastAddedCount: result.addedCount,
totalAdded: (status.totalAdded || 0) + result.addedCount,
});
};
// 실제로 추가된 경우에만 lastAddedCount 업데이트
if (result.addedCount > 0) {
updateData.lastAddedCount = result.addedCount;
}
await updateStatus(botId, updateData);
fastify.log.info(`[${botId}] 초기 동기화 완료: ${result.addedCount}개 추가`);
} catch (err) {
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);

View file

@ -0,0 +1,136 @@
import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js';
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
import { formatDate, formatTime } from '../../utils/date.js';
import config from '../../config/index.js';
const X_CATEGORY_ID = 3;
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
const DEFAULT_USERNAME = 'realfromis_9';
/**
* X(Twitter) 관련 관리자 라우트
*/
export default async function xRoutes(fastify) {
const { db, meilisearch } = fastify;
/**
* GET /api/admin/x/post-info
* X 게시글 정보 조회
*/
fastify.get('/post-info', {
schema: {
tags: ['admin/x'],
summary: 'X 게시글 정보 조회',
security: [{ bearerAuth: [] }],
querystring: {
type: 'object',
properties: {
postId: { type: 'string', description: '게시글 ID' },
username: { type: 'string', description: '사용자명 (기본: realfromis_9)' },
},
required: ['postId'],
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { postId, username = DEFAULT_USERNAME } = request.query;
// 게시글 ID 유효성 검사
if (!/^\d+$/.test(postId)) {
return reply.code(400).send({ error: '유효하지 않은 게시글 ID입니다.' });
}
try {
const tweet = await fetchSingleTweet(NITTER_URL, username, postId);
return {
postId: tweet.id,
username,
text: tweet.text,
title: extractTitle(tweet.text),
imageUrls: tweet.imageUrls,
date: tweet.time ? formatDate(tweet.time) : null,
time: tweet.time ? formatTime(tweet.time) : null,
postUrl: tweet.url,
profile: tweet.profile,
};
} catch (err) {
fastify.log.error(`X 게시글 조회 오류: ${err.message}`);
return reply.code(500).send({ error: err.message });
}
});
/**
* POST /api/admin/x/schedule
* X 일정 저장
*/
fastify.post('/schedule', {
schema: {
tags: ['admin/x'],
summary: 'X 일정 저장',
security: [{ bearerAuth: [] }],
body: {
type: 'object',
properties: {
postId: { type: 'string' },
title: { type: 'string' },
content: { type: 'string' },
imageUrls: { type: 'array', items: { type: 'string' } },
date: { type: 'string' },
time: { type: 'string' },
},
required: ['postId', 'title', 'date'],
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { postId, title, content, imageUrls, date, time } = request.body;
try {
// 중복 체크
const [existing] = await db.query(
'SELECT id FROM schedule_x WHERE post_id = ?',
[postId]
);
if (existing.length > 0) {
return reply.code(409).send({ error: '이미 등록된 게시글입니다.' });
}
// schedules 테이블에 저장
const [result] = await db.query(
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
[X_CATEGORY_ID, title, date, time || null]
);
const scheduleId = result.insertId;
// schedule_x 테이블에 저장
await db.query(
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
[scheduleId, postId, content || null, imageUrls?.length > 0 ? JSON.stringify(imageUrls) : null]
);
// Meilisearch 동기화
const [categoryRows] = await db.query(
'SELECT name, color FROM schedule_categories WHERE id = ?',
[X_CATEGORY_ID]
);
const category = categoryRows[0] || {};
await addOrUpdateSchedule(meilisearch, {
id: scheduleId,
title,
date,
time: time || '',
category_id: X_CATEGORY_ID,
category_name: category.name || '',
category_color: category.color || '',
source_name: '',
});
return { success: true, scheduleId };
} catch (err) {
fastify.log.error(`X 일정 저장 오류: ${err.message}`);
return reply.code(500).send({ error: err.message });
}
});
}

View file

@ -0,0 +1,164 @@
import { fetchVideoInfo } from '../../services/youtube/api.js';
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
const YOUTUBE_CATEGORY_ID = 2;
/**
* YouTube 관련 관리자 라우트
*/
export default async function youtubeRoutes(fastify) {
const { db, meilisearch } = fastify;
/**
* GET /api/admin/youtube/video-info
* YouTube 영상 정보 조회
*/
fastify.get('/video-info', {
schema: {
tags: ['admin/youtube'],
summary: 'YouTube 영상 정보 조회',
security: [{ bearerAuth: [] }],
querystring: {
type: 'object',
properties: {
url: { type: 'string', description: 'YouTube URL' },
},
required: ['url'],
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { url } = request.query;
// YouTube URL에서 video ID 추출
const videoId = extractVideoId(url);
if (!videoId) {
return reply.code(400).send({ error: '유효하지 않은 YouTube URL입니다.' });
}
try {
const video = await fetchVideoInfo(videoId);
if (!video) {
return reply.code(404).send({ error: '영상을 찾을 수 없습니다.' });
}
return {
videoId: video.videoId,
title: video.title,
channelId: video.channelId,
channelName: video.channelTitle,
publishedAt: video.publishedAt,
date: video.date,
time: video.time,
videoType: video.videoType,
videoUrl: video.videoUrl,
};
} catch (err) {
fastify.log.error(`YouTube 영상 조회 오류: ${err.message}`);
return reply.code(500).send({ error: err.message });
}
});
/**
* POST /api/admin/youtube/schedule
* YouTube 일정 저장
*/
fastify.post('/schedule', {
schema: {
tags: ['admin/youtube'],
summary: 'YouTube 일정 저장',
security: [{ bearerAuth: [] }],
body: {
type: 'object',
properties: {
videoId: { type: 'string' },
title: { type: 'string' },
channelId: { type: 'string' },
channelName: { type: 'string' },
date: { type: 'string' },
time: { type: 'string' },
videoType: { type: 'string' },
},
required: ['videoId', 'title', 'date'],
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { videoId, title, channelId, channelName, date, time, videoType } = request.body;
try {
// 중복 체크
const [existing] = await db.query(
'SELECT id FROM schedule_youtube WHERE video_id = ?',
[videoId]
);
if (existing.length > 0) {
return reply.code(409).send({ error: '이미 등록된 영상입니다.' });
}
// schedules 테이블에 저장
const [result] = await db.query(
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
[YOUTUBE_CATEGORY_ID, title, date, time || null]
);
const scheduleId = result.insertId;
// schedule_youtube 테이블에 저장
await db.query(
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
[scheduleId, videoId, videoType || 'video', channelId, channelName]
);
// Meilisearch 동기화
const [categoryRows] = await db.query(
'SELECT name, color FROM schedule_categories WHERE id = ?',
[YOUTUBE_CATEGORY_ID]
);
const category = categoryRows[0] || {};
await addOrUpdateSchedule(meilisearch, {
id: scheduleId,
title,
date,
time: time || '',
category_id: YOUTUBE_CATEGORY_ID,
category_name: category.name || '',
category_color: category.color || '',
source_name: channelName || '',
});
return { success: true, scheduleId };
} catch (err) {
fastify.log.error(`YouTube 일정 저장 오류: ${err.message}`);
return reply.code(500).send({ error: err.message });
}
});
}
/**
* YouTube URL에서 video ID 추출
*/
function extractVideoId(url) {
if (!url) return null;
const patterns = [
// https://www.youtube.com/watch?v=VIDEO_ID
/(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/,
// https://youtu.be/VIDEO_ID
/(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/,
// https://www.youtube.com/shorts/VIDEO_ID
/(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
// https://www.youtube.com/embed/VIDEO_ID
/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
// https://www.youtube.com/v/VIDEO_ID
/(?:youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
return match[1];
}
}
return null;
}

View file

@ -4,6 +4,8 @@ import albumsRoutes from './albums/index.js';
import schedulesRoutes from './schedules/index.js';
import statsRoutes from './stats/index.js';
import botsRoutes from './admin/bots.js';
import youtubeAdminRoutes from './admin/youtube.js';
import xAdminRoutes from './admin/x.js';
/**
* 라우트 통합
@ -27,4 +29,10 @@ export default async function routes(fastify) {
// 관리자 - 봇 라우트
fastify.register(botsRoutes, { prefix: '/admin/bots' });
// 관리자 - YouTube 라우트
fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' });
// 관리자 - X 라우트
fastify.register(xAdminRoutes, { prefix: '/admin/x' });
}

View file

@ -11,6 +11,22 @@ export default async function schedulesRoutes(fastify) {
// 추천 검색어 라우트 등록
fastify.register(suggestionsRoutes, { prefix: '/suggestions' });
/**
* GET /api/schedules/categories
* 카테고리 목록 조회
*/
fastify.get('/categories', {
schema: {
tags: ['schedules'],
summary: '카테고리 목록 조회',
},
}, async (request, reply) => {
const [categories] = await db.query(
'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC'
);
return categories;
});
/**
* GET /api/schedules
* 검색 모드: search 파라미터가 있으면 Meilisearch 검색
@ -121,13 +137,53 @@ export default async function schedulesRoutes(fastify) {
};
} else if (s.category_id === 3 && s.x_post_id) {
result.source = {
name: 'X',
name: '',
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
};
}
return result;
});
/**
* DELETE /api/schedules/:id
* 일정 삭제 (인증 필요)
*/
fastify.delete('/:id', {
schema: {
tags: ['schedules'],
summary: '일정 삭제',
security: [{ bearerAuth: [] }],
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params;
// 일정 존재 확인
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
if (existing.length === 0) {
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
}
// 관련 테이블 삭제 (외래 키)
await db.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]);
await db.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]);
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
await db.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]);
// 메인 테이블 삭제
await db.query('DELETE FROM schedules WHERE id = ?', [id]);
// Meilisearch에서도 삭제
try {
const { deleteSchedule } = await import('../../services/meilisearch/index.js');
await deleteSchedule(meilisearch, id);
} catch (err) {
fastify.log.error(`Meilisearch 삭제 오류: ${err.message}`);
}
return { success: true };
});
}
/**
@ -247,7 +303,7 @@ async function handleMonthlySchedules(db, year, month) {
};
} else if (s.category_id === 3 && s.x_post_id) {
schedule.source = {
name: 'X',
name: '',
url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
};
}
@ -271,6 +327,11 @@ async function handleMonthlySchedules(db, year, month) {
// 생일 일정 추가
for (const member of birthdays) {
const birthDate = new Date(member.birth_date);
const birthYear = birthDate.getFullYear();
// 조회 연도가 생년보다 이전이면 스킵
if (year < birthYear) continue;
const birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate());
const dateKey = birthdayThisYear.toISOString().split('T')[0];

View file

@ -137,6 +137,16 @@ function formatScheduleResponse(hit) {
? hit.member_names.split(',').map(name => name.trim()).filter(Boolean)
: [];
// source 객체 구성 (X는 name 비움)
let source = null;
if (hit.category_id === 2 && hit.source_name) {
// YouTube
source = { name: hit.source_name, url: null };
} else if (hit.category_id === 3) {
// X (name 비움)
source = { name: '', url: null };
}
return {
id: hit.id,
title: hit.title,
@ -146,7 +156,7 @@ function formatScheduleResponse(hit) {
name: hit.category_name,
color: hit.category_color,
},
source_name: hit.source_name || null,
source,
members,
_rankingScore: hit._rankingScore,
};

View file

@ -15,8 +15,8 @@ const inko = new Inko();
const CONFIG = {
// 추천 검색어 최소 검색 횟수 비율 (최대 대비)
MIN_COUNT_RATIO: 0.01,
// 최소 임계값 (데이터 적을 때)
MIN_COUNT_FLOOR: 5,
// 최소 임계값 (데이터 적을 때 오타 방지)
MIN_COUNT_FLOOR: 10,
// Redis 키 prefix
REDIS_PREFIX: 'suggest:',
// 캐시 TTL (초)
@ -24,7 +24,6 @@ const CONFIG = {
PREFIX: 3600, // prefix 검색: 1시간
BIGRAM: 86400, // bi-gram: 24시간
POPULAR: 600, // 인기 검색어: 10분
MAX_COUNT: 3600, // 최대 횟수: 1시간
},
};
@ -208,6 +207,7 @@ export class SuggestionService {
/**
* Prefix 매칭
* - GREATEST() 동적 임계값 적용: MAX(count) * 1% 또는 최소 10
*/
async getPrefixSuggestions(prefix, koreanPrefix, limit) {
try {
@ -216,19 +216,21 @@ export class SuggestionService {
if (koreanPrefix) {
// 영어 + 한글 변환 둘 다 검색
[rows] = await this.db.query(
`SELECT query, count FROM suggestion_queries
WHERE query LIKE ? OR query LIKE ?
`SELECT query FROM suggestion_queries
WHERE (query LIKE ? OR query LIKE ?)
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
ORDER BY count DESC, last_searched_at DESC
LIMIT ?`,
[`${prefix}%`, `${koreanPrefix}%`, limit]
[`${prefix}%`, `${koreanPrefix}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
);
} else {
[rows] = await this.db.query(
`SELECT query, count FROM suggestion_queries
`SELECT query FROM suggestion_queries
WHERE query LIKE ?
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
ORDER BY count DESC, last_searched_at DESC
LIMIT ?`,
[`${prefix}%`, limit]
[`${prefix}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
);
}
@ -241,15 +243,17 @@ export class SuggestionService {
/**
* 초성 검색
* - GREATEST() 동적 임계값 적용
*/
async getChosungSuggestions(chosung, limit) {
try {
const [rows] = await this.db.query(
`SELECT word, count FROM suggestion_chosung
`SELECT word FROM suggestion_chosung
WHERE chosung LIKE ?
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_chosung), ?)
ORDER BY count DESC
LIMIT ?`,
[`${chosung}%`, limit]
[`${chosung}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
);
return rows.map(r => r.word);
@ -261,6 +265,7 @@ export class SuggestionService {
/**
* 인기 검색어 조회
* - GREATEST() 동적 임계값 적용
*/
async getPopularQueries(limit = 10) {
try {
@ -272,12 +277,13 @@ export class SuggestionService {
return JSON.parse(cached);
}
// DB 조회
// DB 조회 (동적 임계값 이상만)
const [rows] = await this.db.query(
`SELECT query, count FROM suggestion_queries
`SELECT query FROM suggestion_queries
WHERE count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
ORDER BY count DESC
LIMIT ?`,
[limit]
[CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
);
const result = rows.map(r => r.query);

View file

@ -20,11 +20,22 @@ async function xBotPlugin(fastify, opts) {
}
/**
* X 프로필 캐시 저장
* X 프로필 저장 (DB + Redis 캐시)
*/
async function cacheProfile(username, profile) {
async function saveProfile(username, profile) {
if (!profile.displayName && !profile.avatarUrl) return;
// DB에 저장 (upsert)
await fastify.db.query(`
INSERT INTO x_profiles (username, display_name, avatar_url)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
avatar_url = VALUES(avatar_url),
updated_at = CURRENT_TIMESTAMP
`, [username, profile.displayName, profile.avatarUrl]);
// Redis 캐시에도 저장
const data = {
username,
displayName: profile.displayName,
@ -139,8 +150,8 @@ async function xBotPlugin(fastify, opts) {
async function syncNewTweets(bot) {
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username);
// 프로필 캐시 업데이트
await cacheProfile(bot.username, profile);
// 프로필 저장 (DB + 캐시)
await saveProfile(bot.username, profile);
let addedCount = 0;
let ytAddedCount = 0;
@ -178,11 +189,39 @@ async function xBotPlugin(fastify, opts) {
}
/**
* X 프로필 조회
* X 프로필 조회 (Redis 캐시 DB)
*/
async function getProfile(username) {
const data = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`);
return data ? JSON.parse(data) : null;
// Redis 캐시 확인
const cached = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`);
if (cached) {
return JSON.parse(cached);
}
// DB에서 조회
const [rows] = await fastify.db.query(
'SELECT username, display_name, avatar_url, updated_at FROM x_profiles WHERE username = ?',
[username]
);
if (rows.length > 0) {
const row = rows[0];
const data = {
username: row.username,
displayName: row.display_name,
avatarUrl: row.avatar_url,
updatedAt: row.updated_at?.toISOString(),
};
// Redis 캐시에 저장
await fastify.redis.setex(
`${PROFILE_CACHE_PREFIX}${username}`,
PROFILE_TTL,
JSON.stringify(data)
);
return data;
}
return null;
}
fastify.decorate('xBot', {

View file

@ -128,6 +128,58 @@ export function parseTweets(html, username) {
return tweets;
}
/**
* Nitter에서 단일 트윗 조회
*/
export async function fetchSingleTweet(nitterUrl, username, postId) {
const url = `${nitterUrl}/${username}/status/${postId}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`트윗을 찾을 수 없습니다 (${res.status})`);
}
const html = await res.text();
// 메인 트윗 파싱 (main-tweet ~ replies 사이)
const mainTweetMatch = html.match(/<div id="m" class="main-tweet">([\s\S]*?)<div id="r" class="replies">/);
if (!mainTweetMatch) {
throw new Error('트윗 내용을 파싱할 수 없습니다');
}
const container = mainTweetMatch[1];
// 시간
const timeMatch = container.match(/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/);
const time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
// 텍스트
const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/);
let text = '';
if (contentMatch) {
text = contentMatch[1]
.replace(/<br\s*\/?>/g, '\n')
.replace(/<a[^>]*>([^<]*)<\/a>/g, '$1')
.replace(/<[^>]+>/g, '')
.trim();
}
// 이미지
const imageUrls = extractImageUrls(container);
// 프로필 정보
const profile = extractProfile(html);
return {
id: postId,
time,
text,
imageUrls,
url: `https://x.com/${username}/status/${postId}`,
profile,
};
}
/**
* Nitter에서 트윗 수집 ( 페이지만)
*/

View file

@ -29,7 +29,7 @@ function getVideoUrl(videoId, isShorts) {
/**
* 채널의 업로드 플레이리스트 ID 조회
*/
async function getUploadsPlaylistId(channelId) {
export async function getUploadsPlaylistId(channelId) {
const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`;
const res = await fetch(url);
const data = await res.json();
@ -64,9 +64,12 @@ async function getVideoDurations(videoIds) {
/**
* 최근 N개 영상 조회
* @param {string} channelId - 채널 ID
* @param {number} maxResults - 최대 결과
* @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택)
*/
export async function fetchRecentVideos(channelId, maxResults = 10) {
const uploadsId = await getUploadsPlaylistId(channelId);
export async function fetchRecentVideos(channelId, maxResults = 10, uploadsPlaylistId = null) {
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`;
const res = await fetch(url);
@ -102,9 +105,11 @@ export async function fetchRecentVideos(channelId, maxResults = 10) {
/**
* 전체 영상 조회 (페이지네이션)
* @param {string} channelId - 채널 ID
* @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택)
*/
export async function fetchAllVideos(channelId) {
const uploadsId = await getUploadsPlaylistId(channelId);
export async function fetchAllVideos(channelId, uploadsPlaylistId = null) {
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
const videos = [];
let pageToken = '';

View file

@ -1,10 +1,30 @@
import fp from 'fastify-plugin';
import { fetchRecentVideos, fetchAllVideos } from './api.js';
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
import bots from '../../config/bots.js';
const YOUTUBE_CATEGORY_ID = 2;
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
async function youtubeBotPlugin(fastify, opts) {
/**
* uploads playlist ID 조회 (Redis 캐싱)
*/
async function getCachedUploadsPlaylistId(channelId) {
const cacheKey = `${PLAYLIST_CACHE_PREFIX}${channelId}`;
// Redis 캐시 확인
const cached = await fastify.redis.get(cacheKey);
if (cached) {
return cached;
}
// API 호출 후 캐싱 (영구 저장 - 값이 변하지 않음)
const playlistId = await getUploadsPlaylistId(channelId);
await fastify.redis.set(cacheKey, playlistId);
return playlistId;
}
/**
* 멤버 이름 조회
*/
@ -89,7 +109,8 @@ async function youtubeBotPlugin(fastify, opts) {
* 최근 영상 동기화 (정기 실행)
*/
async function syncNewVideos(bot) {
const videos = await fetchRecentVideos(bot.channelId, 10);
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId);
let addedCount = 0;
for (const video of videos) {
@ -106,7 +127,8 @@ async function youtubeBotPlugin(fastify, opts) {
* 전체 영상 동기화 (초기화)
*/
async function syncAllVideos(bot) {
const videos = await fetchAllVideos(bot.channelId);
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
const videos = await fetchAllVideos(bot.channelId, uploadsPlaylistId);
let addedCount = 0;
for (const video of videos) {
@ -137,5 +159,5 @@ async function youtubeBotPlugin(fastify, opts) {
export default fp(youtubeBotPlugin, {
name: 'youtubeBot',
dependencies: ['db'],
dependencies: ['db', 'redis'],
});

View file

@ -1,19 +1,26 @@
services:
fromis9-frontend:
build: .
build: ./frontend
container_name: fromis9-frontend
labels:
- "com.centurylinklabs.watchtower.enable=false"
volumes:
- ./frontend:/app
depends_on:
- fromis9-backend
networks:
- app
restart: unless-stopped
fromis9-backend:
build: ./backend
container_name: fromis9-backend
labels:
- "com.centurylinklabs.watchtower.enable=false"
env_file:
- .env
# 개발 모드
volumes:
- ./backend:/app/backend
- ./frontend:/app/frontend
- backend_modules:/app/backend/node_modules
- frontend_modules:/app/frontend/node_modules
# 배포 모드 (사용 시 위 volumes를 주석처리)
# volumes: []
- ./backend:/app
networks:
- app
- db
@ -39,10 +46,6 @@ services:
- app
restart: unless-stopped
volumes:
backend_modules:
frontend_modules:
networks:
app:
external: true

View file

@ -67,7 +67,7 @@ Base URL: `/api`
**source 객체 (카테고리별):**
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
- X (category_id=3): `{ name: "X", url: "https://x.com/realfromis_9/status/..." }`
- X (category_id=3): `{ name: "", url: "https://x.com/realfromis_9/status/..." }` (name 빈 문자열)
- 기타 카테고리: source 없음
**검색 응답:**
@ -91,9 +91,23 @@ Base URL: `/api`
}
```
### GET /schedules/categories
카테고리 목록 조회
**응답:**
```json
[
{ "id": 1, "name": "기타", "color": "#gray", "sort_order": 0 },
{ "id": 2, "name": "유튜브", "color": "#ff0033", "sort_order": 1 }
]
```
### GET /schedules/:id
일정 상세 조회
### DELETE /schedules/:id
일정 삭제 (인증 필요)
### POST /schedules/sync-search
Meilisearch 전체 동기화 (인증 필요)
@ -117,10 +131,145 @@ Meilisearch 전체 동기화 (인증 필요)
---
## 봇 상태
## 관리자 - 봇 관리 (인증 필요)
### GET /bots
봇 상태 조회
### GET /admin/bots
봇 목록 조회
**응답:**
```json
[
{
"id": "youtube-fromis9",
"name": "fromis_9",
"type": "youtube",
"status": "running",
"last_check_at": "2026-01-18T10:30:00Z",
"last_added_count": 2,
"schedules_added": 150,
"check_interval": 2,
"error_message": null,
"enabled": true
}
]
```
### POST /admin/bots/:id/start
봇 시작
### POST /admin/bots/:id/stop
봇 정지
### POST /admin/bots/:id/sync-all
전체 동기화 (모든 영상/트윗 수집)
**응답:**
```json
{
"success": true,
"addedCount": 25,
"total": 100
}
```
### GET /admin/bots/quota-warning
YouTube API 할당량 경고 조회
**응답:**
```json
{
"active": true,
"message": "YouTube API 할당량 초과",
"timestamp": "2026-01-18T10:00:00Z"
}
```
### DELETE /admin/bots/quota-warning
할당량 경고 해제
---
## 관리자 - YouTube (인증 필요)
### GET /admin/youtube/video-info
YouTube 영상 정보 조회
**Query Parameters:**
- `url` - YouTube URL (watch, shorts, youtu.be 모두 지원)
**응답:**
```json
{
"videoId": "abc123",
"title": "영상 제목",
"channelId": "UCxxx",
"channelName": "채널명",
"date": "2026-01-19",
"time": "15:00:00",
"videoType": "video",
"videoUrl": "https://www.youtube.com/watch?v=abc123"
}
```
### POST /admin/youtube/schedule
YouTube 일정 저장
**Request Body:**
```json
{
"videoId": "abc123",
"title": "영상 제목",
"channelId": "UCxxx",
"channelName": "채널명",
"date": "2026-01-19",
"time": "15:00:00",
"videoType": "video"
}
```
---
## 관리자 - X (인증 필요)
### GET /admin/x/post-info
X 게시글 정보 조회 (Nitter 스크래핑)
**Query Parameters:**
- `postId` - 게시글 ID (필수)
- `username` - 사용자명 (기본: realfromis_9)
**응답:**
```json
{
"postId": "1234567890",
"username": "realfromis_9",
"text": "게시글 전체 내용",
"title": "첫 문단 (자동 추출)",
"imageUrls": ["https://pbs.twimg.com/media/..."],
"date": "2026-01-19",
"time": "15:00:00",
"postUrl": "https://x.com/realfromis_9/status/1234567890",
"profile": {
"displayName": "프로미스나인 (fromis_9)",
"avatarUrl": "https://..."
}
}
```
### POST /admin/x/schedule
X 일정 저장
**Request Body:**
```json
{
"postId": "1234567890",
"title": "게시글 제목",
"content": "게시글 내용",
"imageUrls": ["https://..."],
"date": "2026-01-19",
"time": "15:00:00"
}
```
---

View file

@ -30,6 +30,7 @@ fromis_9/
│ │ │ └── suggestions/ # 추천 검색어
│ │ ├── app.js # Fastify 앱 설정
│ │ └── server.js # 진입점
│ ├── Dockerfile # 백엔드 컨테이너
│ └── package.json
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
@ -47,9 +48,9 @@ fromis_9/
│ │ ├── stores/ # Zustand 스토어
│ │ └── App.jsx
│ ├── vite.config.js
│ ├── Dockerfile # 프론트엔드 컨테이너
│ └── package.json
├── Dockerfile # 개발/배포 통합 (주석 전환)
├── docker-compose.yml
└── .env
```
@ -64,20 +65,24 @@ fromis_9/
┌─────────────────────────────────────────────────────────┐
│ fromis9-frontend (Docker) │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ Vite (:80) │───▶│ Fastify (:3000) │ │
│ │ 프론트엔드 │ │ 백엔드 API │ │
│ └─────────────────┘ └──────────┬──────────────────┘ │
└─────────────────────────────────────┼───────────────────┘
│ fromis9-frontend (:80) │
│ Vite 개발서버 │
│ (프록시: /api → backend) │
└─────────────────────┬───────────────────────────────────┘
┌────────────────────────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ MariaDB │ │ Meilisearch │ │ Redis │
│ (외부 DB망) │ │ (검색 엔진) │ │ (캐시) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────────────────────────────────────────────┐
│ fromis9-backend (:80) │
│ Fastify API │
└─────────────────────┬───────────────────────────────────┘
┌────────────┼────────────┬────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌───────────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ MariaDB │ │Meilisearch│ │ Redis │ │ Nitter │
│ (외부 DB망) │ │ (검색엔진) │ │ (캐시) │ │ (X 스크랩) │
└───────────────┘ └───────────┘ └───────────┘ └───────────┘
```
## 데이터베이스

View file

@ -8,47 +8,89 @@ cd /docker/fromis_9
docker compose up -d --build
```
### 구성
- **Vite** (포트 80): 프론트엔드 개발 서버, HMR 지원
- **Fastify** (포트 3000): 백엔드 API, --watch 모드
- Vite가 `/api`, `/docs` 요청을 localhost:3000으로 프록시
### 컨테이너 구성
| 컨테이너 | 포트 | 설명 |
|---------|------|------|
| `fromis9-frontend` | 80 | Vite 개발 서버, HMR 지원 |
| `fromis9-backend` | 80 | Fastify API, --watch 모드 |
| `fromis9-meilisearch` | 7700 | 검색 엔진 |
| `fromis9-redis` | 6379 | 캐시 |
- Vite가 `/api`, `/docs` 요청을 백엔드로 프록시
### 로그 확인
```bash
# 전체 로그
docker compose logs -f
# 백엔드만
docker compose logs -f fromis9-backend
# 프론트엔드만
docker compose logs -f fromis9-frontend
```
### 코드 수정
- `frontend/`, `backend/` 폴더가 볼륨 마운트됨
- `frontend/`, `backend/` 폴더가 컨테이너에 마운트됨
- `node_modules`도 호스트 폴더에 직접 설치됨
- 코드 수정 시 자동 반영 (HMR, watch)
### 재시작
```bash
# 백엔드만 재시작
docker compose restart fromis9-backend
# 프론트엔드만 재시작
docker compose restart fromis9-frontend
# 전체 재시작
docker compose restart
```
---
## 배포 모드 전환
### 1. Dockerfile 수정
**backend/Dockerfile:**
```dockerfile
# 개발 모드 주석처리
# FROM node:20-alpine
# WORKDIR /app
# ...
# 배포 모드 주석해제
FROM node:20-alpine AS frontend-builder
...
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
```
### 2. docker-compose.yml 수정
```yaml
# volumes 주석처리
# volumes:
# - ./backend:/app/backend
# - ./frontend:/app/frontend
# - backend_modules:/app/backend/node_modules
# - frontend_modules:/app/frontend/node_modules
**frontend/Dockerfile:**
```dockerfile
# 개발 모드 주석처리
# FROM node:20-alpine
# ...
# 배포 모드 주석해제
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;"]
```
### 3. 빌드 및 실행
### 2. 빌드 및 실행
```bash
docker compose up -d --build
```
@ -115,7 +157,7 @@ docker exec caddy caddy reload --config /etc/caddy/Caddyfile
### 네트워크 구조
```
인터넷 → Caddy (:443) → fromis9-frontend (:80) → Fastify (:3000)
인터넷 → Caddy (:443) → fromis9-frontend (:80) → fromis9-backend (:80)
MariaDB, Redis, Meilisearch (내부 네트워크)
```
@ -125,11 +167,11 @@ docker exec caddy caddy reload --config /etc/caddy/Caddyfile
## 유용한 명령어
```bash
# 컨테이너 재시작
docker compose restart fromis9-frontend
# 컨테이너 상태 확인
docker compose ps
# 볼륨 포함 완전 재시작
docker compose down -v && docker compose up -d --build
# 완전 재시작
docker compose down && docker compose up -d --build
# Meilisearch 동기화
curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \

17
frontend/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
# 개발 모드
FROM node:20-alpine
WORKDIR /app
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;"]

View file

@ -38,6 +38,7 @@ 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';
@ -72,7 +73,8 @@ function App() {
<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={<AdminScheduleForm />} />
<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/categories" element={<AdminScheduleCategory />} />
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />

View file

@ -48,5 +48,5 @@ export async function updateSchedule(id, formData) {
// 일정 삭제
export async function deleteSchedule(id) {
return fetchAdminApi(`/api/admin/schedules/${id}`, { method: "DELETE" });
return fetchAdminApi(`/api/schedules/${id}`, { method: "DELETE" });
}

View file

@ -5,12 +5,16 @@
// 기본 fetch 래퍼
export async function fetchApi(url, options = {}) {
const headers = { ...options.headers };
// body가 있을 때만 Content-Type 설정 (DELETE 등 body 없는 요청 대응)
if (options.body) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
headers,
});
if (!response.ok) {

View file

@ -40,13 +40,15 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', show
days.push(i);
}
const startYear = Math.floor(year / 10) * 10 - 1;
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 = () => setViewDate(new Date(year - 10, month, 1));
const nextYearRange = () => setViewDate(new Date(year + 10, month, 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')}`;
@ -57,7 +59,6 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', show
const selectYear = (y) => {
setViewDate(new Date(y, month, 1));
setViewMode('months');
};
const selectMonth = (m) => {
@ -124,7 +125,8 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', show
<button
type="button"
onClick={viewMode === 'years' ? prevYearRange : prevMonth}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
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>

View file

@ -275,11 +275,12 @@ function MobileSchedule() {
const categories = useMemo(() => {
const categoryMap = new Map();
schedules.forEach(s => {
if (s.category_id && !categoryMap.has(s.category_id)) {
categoryMap.set(s.category_id, {
id: s.category_id,
name: s.category_name,
color: s.category_color,
const catId = s.category?.id || s.category_id;
if (catId && !categoryMap.has(catId)) {
categoryMap.set(catId, {
id: catId,
name: s.category?.name || s.category_name,
color: s.category?.color || s.category_color,
});
}
});
@ -717,7 +718,7 @@ function MobileSchedule() {
{/* 일정 점 (최대 3개) */}
<div className="flex gap-0.5 mt-1 h-1.5">
{!isSelected(date) && daySchedules.map((schedule, i) => {
const cat = categories.find(c => c.id === schedule.category_id);
const cat = categories.find(c => c.id === (schedule.category?.id || schedule.category_id));
const color = cat?.color || '#6b7280';
return (
<div
@ -857,7 +858,7 @@ function MobileSchedule() {
<div className={virtualItem.index < searchResults.length - 1 ? "pb-3" : ""}>
<ScheduleCard
schedule={schedule}
categoryColor={getCategoryColor(schedule.category_id)}
categoryColor={getCategoryColor(schedule.category?.id || schedule.category_id)}
categories={categories}
onClick={() => navigate(`/schedule/${schedule.id}`)}
/>
@ -909,7 +910,7 @@ function MobileSchedule() {
<TimelineScheduleCard
key={schedule.id}
schedule={schedule}
categoryColor={getCategoryColor(schedule.category_id)}
categoryColor={getCategoryColor(schedule.category?.id || schedule.category_id)}
categories={categories}
delay={index * 0.05}
onClick={() => navigate(`/schedule/${schedule.id}`)}
@ -926,7 +927,7 @@ function MobileSchedule() {
// () -
function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick }) {
const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
const categoryName = categories.find(c => c.id === (schedule.category?.id || schedule.category_id))?.name || '미분류';
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
const memberList = memberNames.split(',').filter(name => name.trim());
@ -1036,7 +1037,7 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick
// -
function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick }) {
const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
const categoryName = categories.find(c => c.id === (schedule.category?.id || schedule.category_id))?.name || '미분류';
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
const memberList = memberNames.split(',').filter(name => name.trim());
@ -1148,7 +1149,7 @@ function CalendarPicker({
if (!dateMap[date]) {
dateMap[date] = [];
}
const category = categories.find(c => c.id === schedule.category_id);
const category = categories.find(c => c.id === (schedule.category?.id || schedule.category_id));
dateMap[date].push(category?.color || '#6b7280');
});
return dateMap;
@ -1238,8 +1239,10 @@ function CalendarPicker({
}
};
const [yearRangeStart, setYearRangeStart] = useState(Math.floor(year / 12) * 12);
const MIN_YEAR = 2025;
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
const canGoPrevYearRange = yearRangeStart > MIN_YEAR;
//
useEffect(() => {
@ -1330,7 +1333,7 @@ function CalendarPicker({
{!isSelected(item.date) && daySchedules.length > 0 && (
<div className="flex gap-0.5 mt-0.5 h-1.5">
{daySchedules.map((schedule, i) => {
const cat = categories.find(c => c.id === schedule.category_id);
const cat = categories.find(c => c.id === (schedule.category?.id || schedule.category_id));
const color = cat?.color || '#6b7280';
return (
<div
@ -1364,8 +1367,9 @@ function CalendarPicker({
{/* 년도 범위 헤더 */}
<div className="flex items-center justify-between mb-3">
<button
onClick={() => setYearRangeStart(yearRangeStart - 12)}
className="p-1"
onClick={() => canGoPrevYearRange && setYearRangeStart(Math.max(MIN_YEAR, yearRangeStart - 12))}
disabled={!canGoPrevYearRange}
className={`p-1 ${canGoPrevYearRange ? '' : 'opacity-30'}`}
>
<ChevronLeft size={18} />
</button>

View file

@ -273,9 +273,11 @@ function AdminSchedule() {
const days = ['일', '월', '화', '수', '목', '금', '토'];
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
// ( 10 - Schedule.jsx )
const startYear = Math.floor(year / 10) * 10 - 1;
// (2025 , 12 )
const MIN_YEAR = 2025;
const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1);
const yearRange = Array.from({ length: 12 }, (_, i) => startYear + i);
const canGoPrevYearRange = startYear > MIN_YEAR;
// /
const isCurrentYear = (y) => new Date().getFullYear() === y;
@ -482,14 +484,13 @@ function AdminSchedule() {
setSchedules([]); //
};
//
const prevYearRange = () => setCurrentDate(new Date(year - 10, month, 1));
const nextYearRange = () => setCurrentDate(new Date(year + 10, month, 1));
// (12 , 2025 )
const prevYearRange = () => canGoPrevYearRange && setCurrentDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1));
const nextYearRange = () => setCurrentDate(new Date(year + 12, month, 1));
//
//
const selectYear = (newYear) => {
setCurrentDate(new Date(newYear, month, 1));
setViewMode('months');
};
//
@ -730,7 +731,8 @@ function AdminSchedule() {
<div className="flex items-center justify-between mb-4">
<button
onClick={prevYearRange}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
disabled={!canGoPrevYearRange}
className={`p-1.5 rounded-lg transition-colors ${canGoPrevYearRange ? 'hover:bg-gray-100' : 'opacity-30'}`}
>
<ChevronLeft size={20} className="text-gray-600" />
</button>

View file

@ -9,6 +9,35 @@ import useAdminAuth from '../../../hooks/useAdminAuth';
import useToast from '../../../hooks/useToast';
import * as suggestionsApi from '../../../api/admin/suggestions';
// variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.08,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
const cardVariants = {
hidden: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.3, ease: "easeOut" },
},
};
//
const POS_TAGS = [
{ value: 'NNP', label: '고유명사 (NNP)', description: '사람, 그룹, 프로그램 이름 등', examples: '프로미스나인, 송하영, 뮤직뱅크' },
@ -403,9 +432,14 @@ function AdminScheduleDict() {
/>
{/* 메인 콘텐츠 */}
<div className="max-w-5xl mx-auto px-6 py-8">
<motion.div
className="max-w-5xl mx-auto px-6 py-8"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* 브레드크럼 */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
<motion.div variants={itemVariants} className="flex items-center gap-2 text-sm text-gray-400 mb-8">
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
<Home size={16} />
</Link>
@ -415,36 +449,36 @@ function AdminScheduleDict() {
</Link>
<ChevronRight size={14} />
<span className="text-gray-700">사전 관리</span>
</div>
</motion.div>
{/* 타이틀 */}
<div className="mb-8">
<motion.div variants={itemVariants} className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">사전 관리</h1>
<p className="text-gray-500">형태소 분석기 사용자 사전을 관리합니다</p>
</div>
</motion.div>
{/* 통계 카드 */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-gray-100">
<motion.div variants={itemVariants} className="grid grid-cols-4 gap-4 mb-6">
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
<div className="text-2xl font-bold text-gray-900">{posStats.total || 0}</div>
<div className="text-sm text-gray-500">전체 단어</div>
</div>
<div className="bg-white rounded-xl p-4 border border-gray-100">
</motion.div>
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
<div className="text-2xl font-bold text-blue-500">{posStats.NNP || 0}</div>
<div className="text-sm text-gray-500">고유명사</div>
</div>
<div className="bg-white rounded-xl p-4 border border-gray-100">
</motion.div>
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
<div className="text-2xl font-bold text-green-500">{posStats.NNG || 0}</div>
<div className="text-sm text-gray-500">일반명사</div>
</div>
<div className="bg-white rounded-xl p-4 border border-gray-100">
</motion.div>
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
<div className="text-2xl font-bold text-purple-500">{posStats.SL || 0}</div>
<div className="text-sm text-gray-500">외국어</div>
</div>
</div>
</motion.div>
</motion.div>
{/* 단어 추가 영역 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
<motion.div variants={itemVariants} className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
<h3 className="font-bold text-gray-900 mb-4">단어 추가</h3>
<div className="flex items-center gap-3">
<div className="flex-1">
@ -502,10 +536,10 @@ function AdminScheduleDict() {
추가
</button>
</div>
</div>
</motion.div>
{/* 단어 목록 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<motion.div variants={itemVariants} className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
{/* 검색 및 필터 */}
<div className="p-4 border-b border-gray-100 flex items-center gap-4">
<div className="flex-1 relative">
@ -579,7 +613,7 @@ function AdminScheduleDict() {
) : (
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
<table className="w-full">
<thead className="bg-gray-50 sticky top-0">
<thead className="bg-gray-50 sticky top-0 z-30">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">#</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">단어</th>
@ -616,8 +650,8 @@ function AdminScheduleDict() {
)}
</div>
)}
</div>
</div>
</motion.div>
</motion.div>
</AdminLayout>
);
}

View file

@ -0,0 +1,346 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import {
Hash,
Loader2,
Check,
AlertCircle,
Save,
Image as ImageIcon,
} from "lucide-react";
import Toast from "../../../../../components/Toast";
import useToast from "../../../../../hooks/useToast";
// X
const XLogo = ({ size = 24, className = "" }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
/**
* X(Twitter) 일정 추가
* - 게시글 ID 입력 자동으로 정보 조회
*/
function XForm() {
const navigate = useNavigate();
const { toast, setToast } = useToast();
const [postId, setPostId] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [postInfo, setPostInfo] = useState(null);
const [error, setError] = useState(null);
// ID (URL )
const extractPostId = (input) => {
//
if (/^\d+$/.test(input.trim())) {
return input.trim();
}
// URL
const match = input.match(/status\/(\d+)/);
return match ? match[1] : null;
};
// X
const fetchPostInfo = async () => {
const id = extractPostId(postId);
if (!id) {
setError("게시글 ID 또는 URL을 입력해주세요.");
return;
}
setLoading(true);
setError(null);
setPostInfo(null);
try {
const token = localStorage.getItem("adminToken");
const response = await fetch(
`/api/admin/x/post-info?postId=${id}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "게시글 정보를 가져올 수 없습니다.");
}
const data = await response.json();
setPostInfo(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
//
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
fetchPostInfo();
}
};
//
const handleReset = () => {
setPostId("");
setPostInfo(null);
setError(null);
};
//
const handleSubmit = async (e) => {
e.preventDefault();
if (!postInfo) {
setError("먼저 게시글 ID를 입력하고 조회해주세요.");
return;
}
setSaving(true);
try {
const token = localStorage.getItem("adminToken");
const response = await fetch("/api/admin/x/schedule", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
postId: postInfo.postId,
title: postInfo.title,
content: postInfo.text,
imageUrls: postInfo.imageUrls,
date: postInfo.date,
time: postInfo.time,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "일정 저장에 실패했습니다.");
}
sessionStorage.setItem(
"scheduleToast",
JSON.stringify({
type: "success",
message: "X 일정이 추가되었습니다.",
})
);
navigate("/admin/schedule");
} catch (err) {
setToast({
type: "error",
message: err.message,
});
} finally {
setSaving(false);
}
};
return (
<>
<Toast toast={toast} onClose={() => setToast(null)} />
<form onSubmit={handleSubmit} className="space-y-6">
{/* 게시글 ID 입력 */}
<div className="bg-white rounded-2xl shadow-sm p-8">
<div className="flex items-center gap-2 mb-6">
<XLogo size={24} className="text-black" />
<h2 className="text-lg font-bold text-gray-900">X 게시글</h2>
</div>
<div className="space-y-4">
{/* ID 입력 필드 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
게시글 ID 또는 URL *
</label>
<div className="flex gap-2">
<div className="flex-1 relative">
<Hash
size={18}
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type="text"
value={postId}
onChange={(e) => setPostId(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="1234567890 또는 https://x.com/realfromis_9/status/1234567890"
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent"
disabled={loading || postInfo}
/>
</div>
{!postInfo ? (
<button
type="button"
onClick={fetchPostInfo}
disabled={loading || !postId.trim()}
className="px-6 py-3 bg-black text-white rounded-xl hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
>
{loading ? (
<>
<Loader2 size={18} className="animate-spin" />
조회 ...
</>
) : (
"조회"
)}
</button>
) : (
<button
type="button"
onClick={handleReset}
className="px-6 py-3 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-colors whitespace-nowrap"
>
다시 입력
</button>
)}
</div>
</div>
{/* 에러 메시지 */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 p-4 bg-red-50 text-red-600 rounded-xl"
>
<AlertCircle size={18} />
<span>{error}</span>
</motion.div>
)}
{/* 게시글 정보 미리보기 */}
{postInfo && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="border border-green-200 bg-green-50 rounded-xl p-6"
>
<div className="flex items-center gap-2 mb-4">
<Check size={18} className="text-green-500" />
<span className="text-sm font-medium text-green-600">
게시글 정보를 가져왔습니다
</span>
</div>
{/* 프로필 */}
{postInfo.profile?.displayName && (
<div className="flex items-center gap-3 mb-4 pb-4 border-b border-green-200">
{postInfo.profile.avatarUrl && (
<img
src={postInfo.profile.avatarUrl}
alt=""
className="w-10 h-10 rounded-full"
/>
)}
<div>
<p className="font-bold text-gray-900">
{postInfo.profile.displayName}
</p>
<p className="text-sm text-gray-500">@{postInfo.username}</p>
</div>
</div>
)}
{/* 제목 (첫 문단) */}
<div className="mb-4">
<p className="text-xs text-gray-400 mb-1">제목 (자동 추출)</p>
<p className="text-lg font-bold text-gray-900">{postInfo.title}</p>
</div>
{/* 전체 내용 */}
<div className="mb-4">
<p className="text-xs text-gray-400 mb-1">전체 내용</p>
<p className="text-gray-700 whitespace-pre-wrap text-sm">
{postInfo.text}
</p>
</div>
{/* 이미지 */}
{postInfo.imageUrls?.length > 0 && (
<div className="mb-4">
<p className="text-xs text-gray-400 mb-2 flex items-center gap-1">
<ImageIcon size={12} />
이미지 ({postInfo.imageUrls.length})
</p>
<div className="grid grid-cols-4 gap-2">
{postInfo.imageUrls.map((url, index) => (
<div
key={index}
className="aspect-square bg-gray-200 rounded-lg overflow-hidden"
>
<img
src={url}
alt={`이미지 ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
)}
{/* 날짜/시간 */}
<div className="text-sm text-gray-500">
<span className="text-gray-400">게시:</span>{" "}
{postInfo.date} {postInfo.time}
</div>
</motion.div>
)}
</div>
</div>
{/* 버튼 */}
<div className="flex items-center justify-end gap-4">
<button
type="button"
onClick={() => navigate("/admin/schedule")}
className="px-6 py-3 text-gray-700 hover:bg-gray-100 rounded-xl transition-colors font-medium"
>
취소
</button>
<button
type="submit"
disabled={!postInfo || saving}
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? (
<>
<Loader2 size={18} className="animate-spin" />
저장 ...
</>
) : (
<>
<Save size={18} />
추가하기
</>
)}
</button>
</div>
</form>
</>
);
}
export default XForm;

View file

@ -0,0 +1,301 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import {
Youtube,
Link as LinkIcon,
Loader2,
Check,
AlertCircle,
Save,
} from "lucide-react";
import Toast from "../../../../../components/Toast";
import useToast from "../../../../../hooks/useToast";
/**
* YouTube 일정 추가
* - URL 입력 자동으로 영상 정보 조회
* - 조회된 정보로 일정 저장
*/
function YouTubeForm() {
const navigate = useNavigate();
const { toast, setToast } = useToast();
const [url, setUrl] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [videoInfo, setVideoInfo] = useState(null);
const [error, setError] = useState(null);
// YouTube URL
const fetchVideoInfo = async () => {
if (!url.trim()) {
setError("YouTube URL을 입력해주세요.");
return;
}
setLoading(true);
setError(null);
setVideoInfo(null);
try {
const token = localStorage.getItem("adminToken");
const response = await fetch(
`/api/admin/youtube/video-info?url=${encodeURIComponent(url)}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "영상 정보를 가져올 수 없습니다.");
}
const data = await response.json();
setVideoInfo(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// URL
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
fetchVideoInfo();
}
};
//
const handleReset = () => {
setUrl("");
setVideoInfo(null);
setError(null);
};
//
const handleSubmit = async (e) => {
e.preventDefault();
if (!videoInfo) {
setError("먼저 YouTube URL을 입력하고 조회해주세요.");
return;
}
setSaving(true);
try {
const token = localStorage.getItem("adminToken");
const response = await fetch("/api/admin/youtube/schedule", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
videoId: videoInfo.videoId,
title: videoInfo.title,
channelId: videoInfo.channelId,
channelName: videoInfo.channelName,
date: videoInfo.date,
time: videoInfo.time,
videoType: videoInfo.videoType,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "일정 저장에 실패했습니다.");
}
// sessionStorage
sessionStorage.setItem(
"scheduleToast",
JSON.stringify({
type: "success",
message: "YouTube 일정이 추가되었습니다.",
})
);
navigate("/admin/schedule");
} catch (err) {
setToast({
type: "error",
message: err.message,
});
} finally {
setSaving(false);
}
};
return (
<>
<Toast toast={toast} onClose={() => setToast(null)} />
<form onSubmit={handleSubmit} className="space-y-6">
{/* YouTube URL 입력 */}
<div className="bg-white rounded-2xl shadow-sm p-8">
<div className="flex items-center gap-2 mb-6">
<Youtube size={24} className="text-red-500" />
<h2 className="text-lg font-bold text-gray-900">YouTube 영상</h2>
</div>
<div className="space-y-4">
{/* URL 입력 필드 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
YouTube URL *
</label>
<div className="flex gap-2">
<div className="flex-1 relative">
<LinkIcon
size={18}
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="https://www.youtube.com/watch?v=... 또는 https://youtu.be/..."
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
disabled={loading || videoInfo}
/>
</div>
{!videoInfo ? (
<button
type="button"
onClick={fetchVideoInfo}
disabled={loading || !url.trim()}
className="px-6 py-3 bg-red-500 text-white rounded-xl hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
>
{loading ? (
<>
<Loader2 size={18} className="animate-spin" />
조회 ...
</>
) : (
"조회"
)}
</button>
) : (
<button
type="button"
onClick={handleReset}
className="px-6 py-3 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-colors whitespace-nowrap"
>
다시 입력
</button>
)}
</div>
</div>
{/* 에러 메시지 */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 p-4 bg-red-50 text-red-600 rounded-xl"
>
<AlertCircle size={18} />
<span>{error}</span>
</motion.div>
)}
{/* 영상 정보 미리보기 */}
{videoInfo && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="border border-green-200 bg-green-50 rounded-xl p-6"
>
<div className="flex items-start gap-4">
{/* 썸네일 */}
<div className="flex-shrink-0 w-48 aspect-video bg-gray-200 rounded-lg overflow-hidden">
<img
src={`https://img.youtube.com/vi/${videoInfo.videoId}/mqdefault.jpg`}
alt={videoInfo.title}
className="w-full h-full object-cover"
/>
</div>
{/* 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<Check size={18} className="text-green-500" />
<span className="text-sm font-medium text-green-600">
영상 정보를 가져왔습니다
</span>
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2 line-clamp-2">
{videoInfo.title}
</h3>
<div className="space-y-1 text-sm text-gray-600">
<p>
<span className="text-gray-400">채널:</span>{" "}
{videoInfo.channelName}
</p>
<p>
<span className="text-gray-400">업로드:</span>{" "}
{videoInfo.date} {videoInfo.time}
</p>
<p>
<span className="text-gray-400">유형:</span>{" "}
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
videoInfo.videoType === "shorts"
? "bg-pink-100 text-pink-600"
: "bg-blue-100 text-blue-600"
}`}
>
{videoInfo.videoType === "shorts" ? "Shorts" : "Video"}
</span>
</p>
</div>
</div>
</div>
</motion.div>
)}
</div>
</div>
{/* 버튼 */}
<div className="flex items-center justify-end gap-4">
<button
type="button"
onClick={() => navigate("/admin/schedule")}
className="px-6 py-3 text-gray-700 hover:bg-gray-100 rounded-xl transition-colors font-medium"
>
취소
</button>
<button
type="submit"
disabled={!videoInfo || saving}
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? (
<>
<Loader2 size={18} className="animate-spin" />
저장 ...
</>
) : (
<>
<Save size={18} />
추가하기
</>
)}
</button>
</div>
</form>
</>
);
}
export default YouTubeForm;

View file

@ -0,0 +1,78 @@
import { Link } from "react-router-dom";
import { Settings } from "lucide-react";
/**
* 카테고리 선택 컴포넌트
*/
function CategorySelector({ categories, selectedId, onChange }) {
// ( HEX)
const getColorStyle = (color) => {
const colorMap = {
blue: "bg-blue-500",
green: "bg-green-500",
purple: "bg-purple-500",
red: "bg-red-500",
pink: "bg-pink-500",
yellow: "bg-yellow-500",
orange: "bg-orange-500",
gray: "bg-gray-500",
cyan: "bg-cyan-500",
indigo: "bg-indigo-500",
};
if (!color) return { className: "bg-gray-500" };
if (color.startsWith("#")) {
return { style: { backgroundColor: color } };
}
return { className: colorMap[color] || "bg-gray-500" };
};
return (
<div className="bg-white rounded-2xl shadow-sm p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-bold text-gray-900">카테고리 선택</h2>
<Link
to="/admin/schedule/categories"
className="flex items-center gap-1 text-xs text-gray-400 hover:text-primary transition-colors"
>
<Settings size={12} />
카테고리 관리
</Link>
</div>
<div className="grid grid-cols-4 gap-3">
{categories.map((category) => {
const colorStyle = getColorStyle(category.color);
const isSelected = selectedId === category.id;
return (
<button
key={category.id}
type="button"
onClick={() => onChange(category.id)}
className={`flex items-center justify-center gap-2 px-4 py-4 rounded-xl border-2 transition-all ${
isSelected
? "border-primary bg-primary/5 shadow-sm"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
}`}
>
<span
className={`w-3 h-3 rounded-full ${colorStyle.className || ""}`}
style={colorStyle.style}
/>
<span
className={`text-sm font-medium ${
isSelected ? "text-primary" : "text-gray-700"
}`}
>
{category.name}
</span>
</button>
);
})}
</div>
</div>
);
}
export default CategorySelector;

View file

@ -0,0 +1,181 @@
import { useState, useEffect } from "react";
import { useNavigate, Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { Home, ChevronRight } from "lucide-react";
import AdminLayout from "../../../../../components/admin/AdminLayout";
import useAdminAuth from "../../../../../hooks/useAdminAuth";
import * as categoriesApi from "../../../../../api/admin/categories";
import CategorySelector from "./components/CategorySelector";
import YouTubeForm from "./YouTubeForm";
import XForm from "./XForm";
// variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
// ID
const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
};
/**
* 일정 추가 페이지 (카테고리별 분기)
*/
function ScheduleFormPage() {
const navigate = useNavigate();
const { user, isAuthenticated } = useAdminAuth();
const [categories, setCategories] = useState([]);
const [selectedCategory, setSelectedCategory] = useState(null);
const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
//
useEffect(() => {
if (!isAuthenticated) return;
const fetchCategories = async () => {
try {
const data = await categoriesApi.getCategories();
setCategories(data);
//
if (data.length > 0) {
setSelectedCategory(data[0].id);
}
} catch (error) {
console.error("카테고리 로드 오류:", error);
} finally {
setLoading(false);
}
};
fetchCategories();
}, [isAuthenticated]);
//
const renderForm = () => {
switch (selectedCategory) {
case CATEGORY_IDS.YOUTUBE:
return <YouTubeForm />;
case CATEGORY_IDS.X:
return <XForm />;
// ( )
default:
return (
<div className="bg-white rounded-2xl shadow-sm p-8 text-center">
<p className="text-gray-500 mb-4">
카테고리는 아직 전용 폼이 없습니다.
</p>
<button
onClick={() => navigate("/admin/schedule/new-legacy")}
className="px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors"
>
기존 폼으로 추가하기
</button>
</div>
);
}
};
if (loading) {
return (
<AdminLayout user={user}>
<div className="flex items-center justify-center min-h-[400px]">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</AdminLayout>
);
}
return (
<AdminLayout user={user}>
<motion.div
className="max-w-4xl mx-auto px-6 py-8"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* 브레드크럼 */}
<motion.div
variants={itemVariants}
className="flex items-center gap-2 text-sm text-gray-400 mb-8"
>
<Link
to="/admin/dashboard"
className="hover:text-primary transition-colors"
>
<Home size={16} />
</Link>
<ChevronRight size={14} />
<Link
to="/admin/schedule"
className="hover:text-primary transition-colors"
>
일정 관리
</Link>
<ChevronRight size={14} />
<span className="text-gray-700">일정 추가</span>
</motion.div>
{/* 타이틀 */}
<motion.div variants={itemVariants} className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">일정 추가</h1>
<p className="text-gray-500">카테고리를 선택하고 일정을 등록하세요</p>
</motion.div>
{/* 카테고리 선택 */}
<motion.div variants={itemVariants} className="mb-6">
<CategorySelector
categories={categories}
selectedId={selectedCategory}
onChange={(id) => {
setSelectedCategory(id);
setIsInitialLoad(false);
}}
/>
</motion.div>
{/* 카테고리별 폼 */}
<AnimatePresence mode="wait">
<motion.div
key={selectedCategory}
initial={{ opacity: 0, y: 10 }}
animate={{
opacity: 1,
y: 0,
transition: {
duration: isInitialLoad ? 0.4 : 0.15,
ease: "easeOut",
delay: isInitialLoad ? 0.3 : 0,
},
}}
exit={{ opacity: 0, y: -10, transition: { duration: 0.1 } }}
>
{renderForm()}
</motion.div>
</AnimatePresence>
</motion.div>
</AdminLayout>
);
}
export default ScheduleFormPage;

View file

@ -431,7 +431,6 @@ function Schedule() {
const selectYear = (newYear) => {
setCurrentDate(new Date(newYear, month, 1));
setViewMode('months');
};
const selectMonth = (newMonth) => {
@ -568,12 +567,14 @@ function Schedule() {
return year === now.getFullYear() && m === now.getMonth();
};
//
const [yearRangeStart, setYearRangeStart] = useState(currentYear - 1);
// (2025 )
const MIN_YEAR = 2025;
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
const prevYearRange = () => setYearRangeStart(prev => prev - 3);
const nextYearRange = () => setYearRangeStart(prev => prev + 3);
const canGoPrevYearRange = yearRangeStart > MIN_YEAR;
const prevYearRange = () => canGoPrevYearRange && setYearRangeStart(prev => Math.max(MIN_YEAR, prev - 12));
const nextYearRange = () => setYearRangeStart(prev => prev + 12);
//
const getSelectedCategoryNames = () => {
@ -680,7 +681,7 @@ function Schedule() {
className="absolute top-20 left-8 right-8 mx-auto w-80 bg-white rounded-xl shadow-lg border border-gray-200 p-4 z-10"
>
<div className="flex items-center justify-between mb-4">
<button onClick={prevYearRange} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors">
<button onClick={prevYearRange} disabled={!canGoPrevYearRange} className={`p-1.5 rounded-lg transition-colors ${canGoPrevYearRange ? 'hover:bg-gray-100' : 'opacity-30'}`}>
<ChevronLeft size={20} className="text-gray-600" />
</button>
<span className="font-medium text-gray-900">

View file

@ -8,24 +8,14 @@ export default defineConfig({
port: 80,
allowedHosts: true,
proxy: {
// 개발 모드 (localhost:3000)
"/api": {
target: "http://localhost:3000",
target: "http://fromis9-backend:80",
changeOrigin: true,
},
"/docs": {
target: "http://localhost:3000",
target: "http://fromis9-backend:80",
changeOrigin: true,
},
// 배포 모드 (사용 시 위를 주석처리)
// "/api": {
// target: "http://fromis9-backend:3000",
// changeOrigin: true,
// },
// "/docs": {
// target: "http://fromis9-backend:3000",
// changeOrigin: true,
// },
},
},
});