Compare commits

..

No commits in common. "2d469739b7c66c3a874deb86665127f92f58f964" and "841c3c86263d4ad85e62b7e666611aef6a7d2a96" have entirely different histories.

33 changed files with 195 additions and 1869 deletions

View file

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

27
Dockerfile Normal file
View file

@ -0,0 +1,27 @@
# ============================================
# 개발 모드
# ============================================
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"]

View file

@ -1,15 +0,0 @@
# 개발 모드
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

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

View file

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

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

View file

@ -11,22 +11,6 @@ export default async function schedulesRoutes(fastify) {
// 추천 검색어 라우트 등록 // 추천 검색어 라우트 등록
fastify.register(suggestionsRoutes, { prefix: '/suggestions' }); 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 * GET /api/schedules
* 검색 모드: search 파라미터가 있으면 Meilisearch 검색 * 검색 모드: search 파라미터가 있으면 Meilisearch 검색
@ -137,53 +121,13 @@ export default async function schedulesRoutes(fastify) {
}; };
} else if (s.category_id === 3 && s.x_post_id) { } else if (s.category_id === 3 && s.x_post_id) {
result.source = { result.source = {
name: '', name: 'X',
url: `https://x.com/realfromis_9/status/${s.x_post_id}`, url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
}; };
} }
return result; 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 };
});
} }
/** /**
@ -303,7 +247,7 @@ async function handleMonthlySchedules(db, year, month) {
}; };
} else if (s.category_id === 3 && s.x_post_id) { } else if (s.category_id === 3 && s.x_post_id) {
schedule.source = { schedule.source = {
name: '', name: 'X',
url: `https://x.com/realfromis_9/status/${s.x_post_id}`, url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
}; };
} }
@ -327,11 +271,6 @@ async function handleMonthlySchedules(db, year, month) {
// 생일 일정 추가 // 생일 일정 추가
for (const member of birthdays) { for (const member of birthdays) {
const birthDate = new Date(member.birth_date); 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 birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate());
const dateKey = birthdayThisYear.toISOString().split('T')[0]; const dateKey = birthdayThisYear.toISOString().split('T')[0];

View file

@ -137,16 +137,6 @@ function formatScheduleResponse(hit) {
? hit.member_names.split(',').map(name => name.trim()).filter(Boolean) ? 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 { return {
id: hit.id, id: hit.id,
title: hit.title, title: hit.title,
@ -156,7 +146,7 @@ function formatScheduleResponse(hit) {
name: hit.category_name, name: hit.category_name,
color: hit.category_color, color: hit.category_color,
}, },
source, source_name: hit.source_name || null,
members, members,
_rankingScore: hit._rankingScore, _rankingScore: hit._rankingScore,
}; };

View file

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

View file

@ -20,22 +20,11 @@ async function xBotPlugin(fastify, opts) {
} }
/** /**
* X 프로필 저장 (DB + Redis 캐시) * X 프로필 캐시 저장
*/ */
async function saveProfile(username, profile) { async function cacheProfile(username, profile) {
if (!profile.displayName && !profile.avatarUrl) return; 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 = { const data = {
username, username,
displayName: profile.displayName, displayName: profile.displayName,
@ -150,8 +139,8 @@ async function xBotPlugin(fastify, opts) {
async function syncNewTweets(bot) { async function syncNewTweets(bot) {
const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username); const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username);
// 프로필 저장 (DB + 캐시) // 프로필 캐시 업데이트
await saveProfile(bot.username, profile); await cacheProfile(bot.username, profile);
let addedCount = 0; let addedCount = 0;
let ytAddedCount = 0; let ytAddedCount = 0;
@ -189,39 +178,11 @@ async function xBotPlugin(fastify, opts) {
} }
/** /**
* X 프로필 조회 (Redis 캐시 DB) * X 프로필 조회
*/ */
async function getProfile(username) { async function getProfile(username) {
// Redis 캐시 확인 const data = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`);
const cached = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`); return data ? JSON.parse(data) : null;
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', { fastify.decorate('xBot', {

View file

@ -128,58 +128,6 @@ export function parseTweets(html, username) {
return tweets; 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에서 트윗 수집 ( 페이지만) * Nitter에서 트윗 수집 ( 페이지만)
*/ */

View file

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

View file

@ -1,30 +1,10 @@
import fp from 'fastify-plugin'; import fp from 'fastify-plugin';
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js'; import { fetchRecentVideos, fetchAllVideos } from './api.js';
import bots from '../../config/bots.js'; import bots from '../../config/bots.js';
const YOUTUBE_CATEGORY_ID = 2; const YOUTUBE_CATEGORY_ID = 2;
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
async function youtubeBotPlugin(fastify, opts) { 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;
}
/** /**
* 멤버 이름 조회 * 멤버 이름 조회
*/ */
@ -109,8 +89,7 @@ async function youtubeBotPlugin(fastify, opts) {
* 최근 영상 동기화 (정기 실행) * 최근 영상 동기화 (정기 실행)
*/ */
async function syncNewVideos(bot) { async function syncNewVideos(bot) {
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId); const videos = await fetchRecentVideos(bot.channelId, 10);
const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId);
let addedCount = 0; let addedCount = 0;
for (const video of videos) { for (const video of videos) {
@ -127,8 +106,7 @@ async function youtubeBotPlugin(fastify, opts) {
* 전체 영상 동기화 (초기화) * 전체 영상 동기화 (초기화)
*/ */
async function syncAllVideos(bot) { async function syncAllVideos(bot) {
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId); const videos = await fetchAllVideos(bot.channelId);
const videos = await fetchAllVideos(bot.channelId, uploadsPlaylistId);
let addedCount = 0; let addedCount = 0;
for (const video of videos) { for (const video of videos) {
@ -159,5 +137,5 @@ async function youtubeBotPlugin(fastify, opts) {
export default fp(youtubeBotPlugin, { export default fp(youtubeBotPlugin, {
name: 'youtubeBot', name: 'youtubeBot',
dependencies: ['db', 'redis'], dependencies: ['db'],
}); });

View file

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

View file

@ -67,7 +67,7 @@ Base URL: `/api`
**source 객체 (카테고리별):** **source 객체 (카테고리별):**
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }` - YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
- X (category_id=3): `{ name: "", url: "https://x.com/realfromis_9/status/..." }` (name 빈 문자열) - X (category_id=3): `{ name: "X", url: "https://x.com/realfromis_9/status/..." }`
- 기타 카테고리: source 없음 - 기타 카테고리: source 없음
**검색 응답:** **검색 응답:**
@ -91,23 +91,9 @@ 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 ### GET /schedules/:id
일정 상세 조회 일정 상세 조회
### DELETE /schedules/:id
일정 삭제 (인증 필요)
### POST /schedules/sync-search ### POST /schedules/sync-search
Meilisearch 전체 동기화 (인증 필요) Meilisearch 전체 동기화 (인증 필요)
@ -131,145 +117,10 @@ Meilisearch 전체 동기화 (인증 필요)
--- ---
## 관리자 - 봇 관리 (인증 필요) ## 봇 상태
### GET /admin/bots ### GET /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,7 +30,6 @@ fromis_9/
│ │ │ └── suggestions/ # 추천 검색어 │ │ │ └── suggestions/ # 추천 검색어
│ │ ├── app.js # Fastify 앱 설정 │ │ ├── app.js # Fastify 앱 설정
│ │ └── server.js # 진입점 │ │ └── server.js # 진입점
│ ├── Dockerfile # 백엔드 컨테이너
│ └── package.json │ └── package.json
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본) ├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
@ -48,9 +47,9 @@ fromis_9/
│ │ ├── stores/ # Zustand 스토어 │ │ ├── stores/ # Zustand 스토어
│ │ └── App.jsx │ │ └── App.jsx
│ ├── vite.config.js │ ├── vite.config.js
│ ├── Dockerfile # 프론트엔드 컨테이너
│ └── package.json │ └── package.json
├── Dockerfile # 개발/배포 통합 (주석 전환)
├── docker-compose.yml ├── docker-compose.yml
└── .env └── .env
``` ```
@ -65,24 +64,20 @@ fromis_9/
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ fromis9-frontend (:80) │ │ fromis9-frontend (Docker) │
│ Vite 개발서버 │ │ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ (프록시: /api → backend) │ │ │ Vite (:80) │───▶│ Fastify (:3000) │ │
└─────────────────────┬───────────────────────────────────┘ │ │ 프론트엔드 │ │ 백엔드 API │ │
│ └─────────────────┘ └──────────┬──────────────────┘ │
└─────────────────────────────────────┼───────────────────┘
┌─────────────────────────────────────────────────────────┐
│ fromis9-backend (:80) │ ┌────────────────────────────┼────────────────────────────┐
│ Fastify API │ │ │ │
└─────────────────────┬───────────────────────────────────┘ ▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
┌────────────┼────────────┬────────────┐ │ MariaDB │ │ Meilisearch │ │ Redis │
│ │ │ │ │ (외부 DB망) │ │ (검색 엔진) │ │ (캐시) │
▼ ▼ ▼ ▼ └─────────────────┘ └─────────────────┘ └─────────────────┘
┌───────────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ MariaDB │ │Meilisearch│ │ Redis │ │ Nitter │
│ (외부 DB망) │ │ (검색엔진) │ │ (캐시) │ │ (X 스크랩) │
└───────────────┘ └───────────┘ └───────────┘ └───────────┘
``` ```
## 데이터베이스 ## 데이터베이스

View file

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

View file

@ -1,17 +0,0 @@
# 개발 모드
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,7 +38,6 @@ import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm';
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos'; import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
import AdminSchedule from './pages/pc/admin/AdminSchedule'; import AdminSchedule from './pages/pc/admin/AdminSchedule';
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm'; import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
import ScheduleFormPage from './pages/pc/admin/schedule/form';
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory'; import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots'; import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict'; import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
@ -73,8 +72,7 @@ function App() {
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} /> <Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} /> <Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
<Route path="/admin/schedule" element={<AdminSchedule />} /> <Route path="/admin/schedule" element={<AdminSchedule />} />
<Route path="/admin/schedule/new" element={<ScheduleFormPage />} /> <Route path="/admin/schedule/new" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} /> <Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} /> <Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} /> <Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />

View file

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

View file

@ -5,16 +5,12 @@
// 기본 fetch 래퍼 // 기본 fetch 래퍼
export async function fetchApi(url, options = {}) { 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, { const response = await fetch(url, {
...options, ...options,
headers, headers: {
"Content-Type": "application/json",
...options.headers,
},
}); });
if (!response.ok) { if (!response.ok) {

View file

@ -40,15 +40,13 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', show
days.push(i); days.push(i);
} }
const MIN_YEAR = 2025; const startYear = Math.floor(year / 10) * 10 - 1;
const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1);
const years = Array.from({ length: 12 }, (_, i) => startYear + i); const years = Array.from({ length: 12 }, (_, i) => startYear + i);
const canGoPrevYearRange = startYear > MIN_YEAR;
const prevMonth = () => setViewDate(new Date(year, month - 1, 1)); const prevMonth = () => setViewDate(new Date(year, month - 1, 1));
const nextMonth = () => 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 prevYearRange = () => setViewDate(new Date(year - 10, month, 1));
const nextYearRange = () => setViewDate(new Date(year + 12, month, 1)); const nextYearRange = () => setViewDate(new Date(year + 10, month, 1));
const selectDate = (day) => { const selectDate = (day) => {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
@ -59,6 +57,7 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', show
const selectYear = (y) => { const selectYear = (y) => {
setViewDate(new Date(y, month, 1)); setViewDate(new Date(y, month, 1));
setViewMode('months');
}; };
const selectMonth = (m) => { const selectMonth = (m) => {
@ -125,8 +124,7 @@ function CustomDatePicker({ value, onChange, placeholder = '날짜 선택', show
<button <button
type="button" type="button"
onClick={viewMode === 'years' ? prevYearRange : prevMonth} onClick={viewMode === 'years' ? prevYearRange : prevMonth}
disabled={viewMode === 'years' && !canGoPrevYearRange} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
className={`p-1.5 rounded-lg transition-colors ${viewMode === 'years' && !canGoPrevYearRange ? 'opacity-30' : 'hover:bg-gray-100'}`}
> >
<ChevronLeft size={20} className="text-gray-600" /> <ChevronLeft size={20} className="text-gray-600" />
</button> </button>

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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