Compare commits
5 commits
f73314d5dc
...
7c20e9bb17
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c20e9bb17 | |||
| d9836d2f5d | |||
| f2a15e07d6 | |||
| 39bb6f77f9 | |||
| 8ece4b1850 |
30 changed files with 2462 additions and 154 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -15,6 +15,10 @@ Thumbs.db
|
|||
*.swp
|
||||
*.swo
|
||||
|
||||
# Env (frontend JS 키 등 — 빌드에 포함되더라도 소스 트리에서는 제외)
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS bot_youtube (
|
|||
channel_handle VARCHAR(50),
|
||||
channel_name VARCHAR(100) NOT NULL,
|
||||
banner_url VARCHAR(500),
|
||||
cron_interval INT DEFAULT 2,
|
||||
cron_interval INT DEFAULT NULL,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
|
||||
-- 제목 필터 (선택, JSON 배열)
|
||||
|
|
@ -18,6 +18,11 @@ CREATE TABLE IF NOT EXISTS bot_youtube (
|
|||
-- 다음 주 예정 일정 설정 (JSON)
|
||||
auto_schedule_config JSON,
|
||||
|
||||
-- 주간 집중 폴링 설정 (JSON) — 있으면 cron_interval 대신 사용
|
||||
-- { dayOfWeek: 0~6, startTime: "HH:MM", intervalSeconds: int, durationMinutes: int }
|
||||
-- 새 영상 1개 발견 시 즉시 종료
|
||||
weekly_schedule_config JSON,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
|
|
|
|||
29
backend/sql/schedule_event.sql
Normal file
29
backend/sql/schedule_event.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-- 행사 장소 (카카오맵 기반)
|
||||
CREATE TABLE IF NOT EXISTS event_venues (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
address VARCHAR(300),
|
||||
road_address VARCHAR(300),
|
||||
lat DECIMAL(10, 7),
|
||||
lng DECIMAL(10, 7),
|
||||
kakao_id VARCHAR(30),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_kakao_id (kakao_id)
|
||||
);
|
||||
|
||||
-- 행사 상세 (schedules와 1:1)
|
||||
-- subtype: 'university' (학교 축제) 등 세부 타입 slug
|
||||
-- school_name: 학교 행사의 경우 대학/학교명
|
||||
-- venue_id: 장소 FK (선택)
|
||||
-- post_urls: 인스타/공식 URL 배열 (JSON)
|
||||
CREATE TABLE IF NOT EXISTS schedule_event (
|
||||
schedule_id INT PRIMARY KEY,
|
||||
subtype VARCHAR(30) NOT NULL,
|
||||
school_name VARCHAR(100),
|
||||
venue_id INT,
|
||||
post_urls JSON,
|
||||
poster_image_ids JSON,
|
||||
FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (venue_id) REFERENCES event_venues(id) ON DELETE SET NULL,
|
||||
INDEX idx_subtype (subtype)
|
||||
);
|
||||
|
|
@ -6,6 +6,7 @@ export const CATEGORY_IDS = {
|
|||
VARIETY: 10,
|
||||
BIRTHDAY: 8,
|
||||
DEBUT: 9,
|
||||
EVENT: 11,
|
||||
};
|
||||
|
||||
// 데뷔일 (fromis_9: 2018년 1월 24일)
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import { logActivity } from '../utils/log.js';
|
|||
|
||||
const REDIS_PREFIX = 'bot:status:';
|
||||
const TIMEZONE = 'Asia/Seoul';
|
||||
const MAX_CONSECUTIVE_ERRORS = 10;
|
||||
|
||||
async function schedulerPlugin(fastify, opts) {
|
||||
const tasks = new Map();
|
||||
const burstTimers = new Map(); // weekly 모드 내부 setInterval 핸들
|
||||
let cachedBots = null;
|
||||
|
||||
/**
|
||||
|
|
@ -19,33 +21,51 @@ async function schedulerPlugin(fastify, opts) {
|
|||
const [rows] = await fastify.db.query(
|
||||
'SELECT * FROM bot_youtube'
|
||||
);
|
||||
return rows.map(row => ({
|
||||
id: `youtube-${row.id}`, // DB ID를 문자열 형식으로 변환
|
||||
dbId: row.id,
|
||||
type: 'youtube',
|
||||
channelId: row.channel_id,
|
||||
channelHandle: row.channel_handle,
|
||||
channelName: row.channel_name,
|
||||
bannerUrl: row.banner_url,
|
||||
cron: `*/${row.cron_interval} * * * *`,
|
||||
enabled: row.enabled === 1,
|
||||
titleFilters: row.title_filters
|
||||
? (typeof row.title_filters === 'string'
|
||||
? JSON.parse(row.title_filters)
|
||||
: row.title_filters)
|
||||
: [],
|
||||
defaultMemberIds: row.default_member_ids
|
||||
? (typeof row.default_member_ids === 'string'
|
||||
? JSON.parse(row.default_member_ids)
|
||||
: row.default_member_ids)
|
||||
: [],
|
||||
extractMembersFromDesc: row.extract_members_from_desc === 1,
|
||||
autoScheduleNext: row.auto_schedule_config
|
||||
? (typeof row.auto_schedule_config === 'string'
|
||||
? JSON.parse(row.auto_schedule_config)
|
||||
: row.auto_schedule_config)
|
||||
: null,
|
||||
}));
|
||||
return rows.map(row => {
|
||||
const weekly = row.weekly_schedule_config
|
||||
? (typeof row.weekly_schedule_config === 'string'
|
||||
? JSON.parse(row.weekly_schedule_config)
|
||||
: row.weekly_schedule_config)
|
||||
: null;
|
||||
|
||||
// weekly 모드면 시작 시각에만 트리거, 아니면 cron_interval 분 주기
|
||||
let cronExpr;
|
||||
if (weekly && weekly.startTime && weekly.dayOfWeek !== undefined) {
|
||||
const [h, m] = weekly.startTime.split(':').map(Number);
|
||||
cronExpr = `${m} ${h} * * ${weekly.dayOfWeek}`;
|
||||
} else {
|
||||
cronExpr = `*/${row.cron_interval || 2} * * * *`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `youtube-${row.id}`, // DB ID를 문자열 형식으로 변환
|
||||
dbId: row.id,
|
||||
type: 'youtube',
|
||||
channelId: row.channel_id,
|
||||
channelHandle: row.channel_handle,
|
||||
channelName: row.channel_name,
|
||||
bannerUrl: row.banner_url,
|
||||
cron: cronExpr,
|
||||
enabled: row.enabled === 1,
|
||||
titleFilters: row.title_filters
|
||||
? (typeof row.title_filters === 'string'
|
||||
? JSON.parse(row.title_filters)
|
||||
: row.title_filters)
|
||||
: [],
|
||||
defaultMemberIds: row.default_member_ids
|
||||
? (typeof row.default_member_ids === 'string'
|
||||
? JSON.parse(row.default_member_ids)
|
||||
: row.default_member_ids)
|
||||
: [],
|
||||
extractMembersFromDesc: row.extract_members_from_desc === 1,
|
||||
autoScheduleNext: row.auto_schedule_config
|
||||
? (typeof row.auto_schedule_config === 'string'
|
||||
? JSON.parse(row.auto_schedule_config)
|
||||
: row.auto_schedule_config)
|
||||
: null,
|
||||
weeklySchedule: weekly,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -121,6 +141,7 @@ async function schedulerPlugin(fastify, opts) {
|
|||
totalAdded: 0,
|
||||
lastSyncDuration: null,
|
||||
errorMessage: null,
|
||||
consecutiveErrors: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +171,7 @@ async function schedulerPlugin(fastify, opts) {
|
|||
const updateData = {
|
||||
lastCheckAt: nowKST(),
|
||||
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
||||
consecutiveErrors: 0,
|
||||
};
|
||||
if (setRunningStatus) {
|
||||
updateData.status = 'running';
|
||||
|
|
@ -174,6 +196,102 @@ async function schedulerPlugin(fastify, opts) {
|
|||
invalidateCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 동기화 실행 + 에러 처리 (consecutiveErrors, 자동 정지 포함)
|
||||
*/
|
||||
async function runSync(botId, bot, syncFn, { setRunningStatus = false } = {}) {
|
||||
try {
|
||||
const result = await syncFn(bot);
|
||||
const addedCount = await handleSyncResult(botId, result, { setRunningStatus });
|
||||
fastify.log.info(`[${botId}] 동기화 완료: ${addedCount}개 추가`);
|
||||
if (addedCount > 0) {
|
||||
logActivity(fastify.db, {
|
||||
actor: botId,
|
||||
action: 'sync_complete',
|
||||
category: 'sync',
|
||||
summary: `${botId} 동기화 완료: ${addedCount}개 추가`,
|
||||
details: { addedCount },
|
||||
});
|
||||
}
|
||||
return { ok: true, addedCount };
|
||||
} catch (err) {
|
||||
const prev = await getStatus(botId);
|
||||
const consecutiveErrors = (prev.consecutiveErrors || 0) + 1;
|
||||
await updateStatus(botId, {
|
||||
status: 'error',
|
||||
lastCheckAt: nowKST(),
|
||||
errorMessage: err.message,
|
||||
consecutiveErrors,
|
||||
});
|
||||
fastify.log.error(`[${botId}] 동기화 오류 (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}): ${err.message}`);
|
||||
if (consecutiveErrors === 1) {
|
||||
logActivity(fastify.db, {
|
||||
actor: botId,
|
||||
action: 'error',
|
||||
category: 'sync',
|
||||
summary: `${botId} 동기화 오류: ${err.message}`,
|
||||
details: { error: err.message },
|
||||
});
|
||||
}
|
||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||
fastify.log.warn(`[${botId}] 연속 ${MAX_CONSECUTIVE_ERRORS}회 실패 - 자동 정지`);
|
||||
logActivity(fastify.db, {
|
||||
actor: botId,
|
||||
action: 'stop',
|
||||
category: 'bot',
|
||||
summary: `${botId} 연속 ${MAX_CONSECUTIVE_ERRORS}회 실패로 자동 정지`,
|
||||
details: { error: err.message, consecutiveErrors },
|
||||
});
|
||||
try {
|
||||
await stopBot(botId);
|
||||
} catch (stopErr) {
|
||||
fastify.log.error(`[${botId}] 자동 정지 실패: ${stopErr.message}`);
|
||||
}
|
||||
}
|
||||
return { ok: false, err };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간 집중 폴링 세션 시작 (weekly 모드)
|
||||
* 새 영상 1개 발견 시 즉시 종료, durationMinutes 초과 시도 종료
|
||||
*/
|
||||
async function startWeeklyBurst(botId, bot, syncFn) {
|
||||
if (burstTimers.has(botId)) return; // 이미 실행 중이면 무시
|
||||
|
||||
const intervalSeconds = Math.max(5, bot.weeklySchedule?.intervalSeconds || 30);
|
||||
const durationMinutes = Math.max(1, bot.weeklySchedule?.durationMinutes || 30);
|
||||
const endAt = Date.now() + durationMinutes * 60 * 1000;
|
||||
|
||||
fastify.log.info(`[${botId}] 주간 폴링 시작 (간격 ${intervalSeconds}초, 최대 ${durationMinutes}분)`);
|
||||
|
||||
const stopBurst = (reason) => {
|
||||
const handle = burstTimers.get(botId);
|
||||
if (!handle) return;
|
||||
clearInterval(handle.timer);
|
||||
burstTimers.delete(botId);
|
||||
fastify.log.info(`[${botId}] 주간 폴링 종료: ${reason}`);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (!burstTimers.has(botId)) return;
|
||||
const result = await runSync(botId, bot, syncFn, { setRunningStatus: true });
|
||||
if (!burstTimers.has(botId)) return; // runSync 중 자동 정지 등으로 정리됐을 수 있음
|
||||
if (result.ok && result.addedCount > 0) {
|
||||
stopBurst(`새 영상 ${result.addedCount}개 발견 (stopOnFound)`);
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= endAt) {
|
||||
stopBurst('최대 지속시간 초과');
|
||||
}
|
||||
};
|
||||
|
||||
// 타이머 먼저 등록 → tick에서 burstTimers.has 체크로 중복/중단 판별
|
||||
const timer = setInterval(tick, intervalSeconds * 1000);
|
||||
burstTimers.set(botId, { timer, endAt });
|
||||
await tick();
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 시작
|
||||
*/
|
||||
|
|
@ -188,6 +306,10 @@ async function schedulerPlugin(fastify, opts) {
|
|||
tasks.get(botId).stop();
|
||||
tasks.delete(botId);
|
||||
}
|
||||
if (burstTimers.has(botId)) {
|
||||
clearInterval(burstTimers.get(botId).timer);
|
||||
burstTimers.delete(botId);
|
||||
}
|
||||
|
||||
// DB enabled 활성화
|
||||
await setEnabled(botId, true);
|
||||
|
|
@ -200,33 +322,10 @@ async function schedulerPlugin(fastify, opts) {
|
|||
// cron 태스크 등록 (한국 시간 기준)
|
||||
const task = cron.schedule(bot.cron, async () => {
|
||||
fastify.log.info(`[${botId}] 동기화 시작`);
|
||||
try {
|
||||
const result = await syncFn(bot);
|
||||
const addedCount = await handleSyncResult(botId, result, { setRunningStatus: true });
|
||||
fastify.log.info(`[${botId}] 동기화 완료: ${addedCount}개 추가`);
|
||||
if (addedCount > 0) {
|
||||
logActivity(fastify.db, {
|
||||
actor: botId,
|
||||
action: 'sync_complete',
|
||||
category: 'sync',
|
||||
summary: `${botId} 동기화 완료: ${addedCount}개 추가`,
|
||||
details: { addedCount },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
await updateStatus(botId, {
|
||||
status: 'error',
|
||||
lastCheckAt: nowKST(),
|
||||
errorMessage: err.message,
|
||||
});
|
||||
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);
|
||||
logActivity(fastify.db, {
|
||||
actor: botId,
|
||||
action: 'error',
|
||||
category: 'sync',
|
||||
summary: `${botId} 동기화 오류: ${err.message}`,
|
||||
details: { error: err.message },
|
||||
});
|
||||
if (bot.weeklySchedule) {
|
||||
await startWeeklyBurst(botId, bot, syncFn);
|
||||
} else {
|
||||
await runSync(botId, bot, syncFn, { setRunningStatus: true });
|
||||
}
|
||||
}, { timezone: TIMEZONE });
|
||||
|
||||
|
|
@ -234,31 +333,9 @@ async function schedulerPlugin(fastify, opts) {
|
|||
await updateStatus(botId, { status: 'running' });
|
||||
fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`);
|
||||
|
||||
// 즉시 1회 실행 (meilisearch는 스케줄 시간에만 실행)
|
||||
if (bot.type !== 'meilisearch') {
|
||||
try {
|
||||
const result = await syncFn(bot);
|
||||
const addedCount = await handleSyncResult(botId, result);
|
||||
fastify.log.info(`[${botId}] 초기 동기화 완료: ${addedCount}개 추가`);
|
||||
if (addedCount > 0) {
|
||||
logActivity(fastify.db, {
|
||||
actor: botId,
|
||||
action: 'sync_complete',
|
||||
category: 'sync',
|
||||
summary: `${botId} 초기 동기화 완료: ${addedCount}개 추가`,
|
||||
details: { addedCount },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
|
||||
logActivity(fastify.db, {
|
||||
actor: botId,
|
||||
action: 'error',
|
||||
category: 'sync',
|
||||
summary: `${botId} 초기 동기화 오류: ${err.message}`,
|
||||
details: { error: err.message },
|
||||
});
|
||||
}
|
||||
// 즉시 1회 실행: meilisearch와 weekly 모드는 제외 (weekly는 지정 시각에만)
|
||||
if (bot.type !== 'meilisearch' && !bot.weeklySchedule) {
|
||||
await runSync(botId, bot, syncFn, { setRunningStatus: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,6 +347,11 @@ async function schedulerPlugin(fastify, opts) {
|
|||
tasks.get(botId).stop();
|
||||
tasks.delete(botId);
|
||||
}
|
||||
// weekly 모드 burst 타이머도 정리
|
||||
if (burstTimers.has(botId)) {
|
||||
clearInterval(burstTimers.get(botId).timer);
|
||||
burstTimers.delete(botId);
|
||||
}
|
||||
// DB enabled 비활성화
|
||||
await setEnabled(botId, false);
|
||||
await updateStatus(botId, { status: 'stopped' });
|
||||
|
|
|
|||
357
backend/src/routes/admin/events.js
Normal file
357
backend/src/routes/admin/events.js
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import { CATEGORY_IDS } from '../../config/index.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
import { uploadEventPoster } from '../../services/image.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
import { syncScheduleById } from '../../services/meilisearch/index.js';
|
||||
|
||||
const EVENT_CATEGORY_ID = CATEGORY_IDS.EVENT;
|
||||
const VALID_SUBTYPES = ['university'];
|
||||
|
||||
/**
|
||||
* 장소를 upsert (kakao_id 기준) 후 venue_id 반환
|
||||
*/
|
||||
async function upsertVenue(db, venue) {
|
||||
if (!venue) return null;
|
||||
if (venue.id) return venue.id;
|
||||
if (!venue.name) return null;
|
||||
|
||||
// kakao_id가 있으면 먼저 조회
|
||||
if (venue.kakao_id) {
|
||||
const [rows] = await db.query('SELECT id FROM event_venues WHERE kakao_id = ?', [venue.kakao_id]);
|
||||
if (rows.length > 0) return rows[0].id;
|
||||
}
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO event_venues (name, address, road_address, lat, lng, kakao_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
venue.name,
|
||||
venue.address || null,
|
||||
venue.road_address || venue.roadAddress || null,
|
||||
venue.lat ?? null,
|
||||
venue.lng ?? null,
|
||||
venue.kakao_id || venue.kakaoId || null,
|
||||
]
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart에서 payload(JSON 문자열) + poster 파일들 추출
|
||||
*/
|
||||
async function parseMultipartEventForm(request) {
|
||||
const parts = request.parts();
|
||||
let payload = null;
|
||||
const posterFiles = [];
|
||||
|
||||
for await (const part of parts) {
|
||||
if (part.type === 'file') {
|
||||
const buf = await part.toBuffer();
|
||||
posterFiles.push({
|
||||
filename: part.filename,
|
||||
buffer: buf,
|
||||
mimetype: part.mimetype,
|
||||
});
|
||||
} else if (part.fieldname === 'payload') {
|
||||
payload = JSON.parse(part.value);
|
||||
}
|
||||
}
|
||||
|
||||
return { payload, posterFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* images 테이블에 INSERT 후 id 반환
|
||||
*/
|
||||
async function saveImageRecord(db, { originalUrl, mediumUrl, thumbUrl }) {
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)`,
|
||||
[originalUrl, mediumUrl, thumbUrl]
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 행사 관련 관리자 라우트
|
||||
*/
|
||||
export default async function eventsRoutes(fastify) {
|
||||
const { db, meilisearch } = fastify;
|
||||
|
||||
/**
|
||||
* GET /api/admin/events/:id
|
||||
* 행사 상세 조회 (수정 폼용)
|
||||
*/
|
||||
fastify.get('/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
const [rows] = await db.query(`
|
||||
SELECT s.id, s.title, s.date, s.time,
|
||||
se.subtype, se.school_name, se.venue_id, se.post_urls, se.poster_image_ids,
|
||||
ev.name as venue_name, ev.address as venue_address,
|
||||
ev.road_address as venue_road_address, ev.lat as venue_lat, ev.lng as venue_lng,
|
||||
ev.kakao_id as venue_kakao_id
|
||||
FROM schedules s
|
||||
JOIN schedule_event se ON s.id = se.schedule_id
|
||||
LEFT JOIN event_venues ev ON se.venue_id = ev.id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return reply.code(404).send({ error: '행사를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
|
||||
// 멤버
|
||||
const [memberRows] = await db.query(
|
||||
'SELECT member_id FROM schedule_members WHERE schedule_id = ?',
|
||||
[id]
|
||||
);
|
||||
const memberIds = memberRows.map(m => m.member_id);
|
||||
|
||||
// 포스터 이미지 (순서 유지)
|
||||
const posterIds = r.poster_image_ids
|
||||
? (typeof r.poster_image_ids === 'string' ? JSON.parse(r.poster_image_ids) : r.poster_image_ids)
|
||||
: [];
|
||||
let posters = [];
|
||||
if (posterIds.length > 0) {
|
||||
const [posterRows] = await db.query(
|
||||
`SELECT id, original_url, medium_url, thumb_url FROM images WHERE id IN (?) ORDER BY FIELD(id, ?)`,
|
||||
[posterIds, posterIds]
|
||||
);
|
||||
posters = posterRows.map(p => ({
|
||||
id: p.id,
|
||||
originalUrl: p.original_url,
|
||||
mediumUrl: p.medium_url,
|
||||
thumbUrl: p.thumb_url,
|
||||
}));
|
||||
}
|
||||
|
||||
const date = r.date instanceof Date
|
||||
? r.date.toISOString().split('T')[0]
|
||||
: String(r.date).split('T')[0];
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
date,
|
||||
time: r.time ? r.time.substring(0, 5) : '',
|
||||
subtype: r.subtype,
|
||||
schoolName: r.school_name || '',
|
||||
memberIds,
|
||||
venue: r.venue_id ? {
|
||||
id: r.venue_id,
|
||||
name: r.venue_name,
|
||||
address: r.venue_address,
|
||||
roadAddress: r.venue_road_address,
|
||||
lat: r.venue_lat,
|
||||
lng: r.venue_lng,
|
||||
kakao_id: r.venue_kakao_id,
|
||||
} : null,
|
||||
postUrls: r.post_urls
|
||||
? (typeof r.post_urls === 'string' ? JSON.parse(r.post_urls) : r.post_urls)
|
||||
: [],
|
||||
posters,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/events
|
||||
* 행사 생성 (multipart/form-data: payload + poster 파일들)
|
||||
*/
|
||||
fastify.post('/', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { payload, posterFiles } = await parseMultipartEventForm(request);
|
||||
|
||||
if (!payload) {
|
||||
return reply.code(400).send({ error: 'payload가 필요합니다.' });
|
||||
}
|
||||
|
||||
const {
|
||||
title, date, time, subtype = 'university', schoolName,
|
||||
memberIds = [], venue, postUrls = [],
|
||||
} = payload;
|
||||
|
||||
if (!title || !date || !schoolName) {
|
||||
return reply.code(400).send({ error: '제목/날짜/학교명은 필수입니다.' });
|
||||
}
|
||||
if (!VALID_SUBTYPES.includes(subtype)) {
|
||||
return reply.code(400).send({ error: `알 수 없는 subtype: ${subtype}` });
|
||||
}
|
||||
if (!venue) {
|
||||
return reply.code(400).send({ error: '장소가 필요합니다.' });
|
||||
}
|
||||
|
||||
const scheduleId = await withTransaction(db, async (conn) => {
|
||||
// 1) venue upsert
|
||||
const venueId = await upsertVenue(conn, venue);
|
||||
|
||||
// 2) schedules INSERT
|
||||
const [sResult] = await conn.query(
|
||||
`INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)`,
|
||||
[EVENT_CATEGORY_ID, title, date, time || null]
|
||||
);
|
||||
const sid = sResult.insertId;
|
||||
|
||||
// 3) schedule_event INSERT (poster는 트랜잭션 후 업로드, 그 때 UPDATE)
|
||||
await conn.query(
|
||||
`INSERT INTO schedule_event (schedule_id, subtype, school_name, venue_id, post_urls)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[
|
||||
sid,
|
||||
subtype,
|
||||
schoolName,
|
||||
venueId,
|
||||
postUrls.length > 0 ? JSON.stringify(postUrls) : null,
|
||||
]
|
||||
);
|
||||
|
||||
// 4) 멤버 연결
|
||||
if (memberIds.length > 0) {
|
||||
const values = memberIds.map(mid => [sid, mid]);
|
||||
await conn.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
|
||||
}
|
||||
|
||||
return sid;
|
||||
});
|
||||
|
||||
// 5) 포스터 업로드 (트랜잭션 밖 — S3 I/O)
|
||||
if (posterFiles.length > 0) {
|
||||
const uploadedIds = [];
|
||||
for (let i = 0; i < posterFiles.length; i++) {
|
||||
const ext = (posterFiles[i].filename.split('.').pop() || 'webp').toLowerCase();
|
||||
const filename = `${String(i + 1).padStart(2, '0')}.${ext === 'jpg' ? 'jpeg' : ext}`;
|
||||
const urls = await uploadEventPoster(scheduleId, filename, posterFiles[i].buffer);
|
||||
const imgId = await saveImageRecord(db, urls);
|
||||
uploadedIds.push(imgId);
|
||||
}
|
||||
await db.query(
|
||||
`UPDATE schedule_event SET poster_image_ids = ? WHERE schedule_id = ?`,
|
||||
[JSON.stringify(uploadedIds), scheduleId]
|
||||
);
|
||||
}
|
||||
|
||||
// Meilisearch 동기화
|
||||
await syncScheduleById(meilisearch, db, scheduleId);
|
||||
|
||||
logActivity(db, {
|
||||
actor: 'admin', action: 'create', category: 'schedule',
|
||||
targetType: 'event_schedule', targetId: scheduleId,
|
||||
summary: `행사 생성: ${title}`,
|
||||
});
|
||||
|
||||
reply.code(201);
|
||||
return { id: scheduleId };
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/admin/events/:id
|
||||
* 행사 수정 (multipart: payload + 새 poster 파일들)
|
||||
* payload.keepPosterIds: 유지할 기존 포스터 ID 배열 (순서대로)
|
||||
*/
|
||||
fastify.put('/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { payload, posterFiles } = await parseMultipartEventForm(request);
|
||||
|
||||
if (!payload) {
|
||||
return reply.code(400).send({ error: 'payload가 필요합니다.' });
|
||||
}
|
||||
|
||||
const [existing] = await db.query('SELECT schedule_id, poster_image_ids FROM schedule_event WHERE schedule_id = ?', [id]);
|
||||
if (existing.length === 0) {
|
||||
return reply.code(404).send({ error: '행사를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const {
|
||||
title, date, time, subtype, schoolName,
|
||||
memberIds = [], venue, postUrls = [], keepPosterIds = [],
|
||||
} = payload;
|
||||
|
||||
await withTransaction(db, async (conn) => {
|
||||
// schedules UPDATE
|
||||
await conn.query(
|
||||
`UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?`,
|
||||
[title, date, time || null, id]
|
||||
);
|
||||
|
||||
// venue upsert
|
||||
const venueId = venue ? await upsertVenue(conn, venue) : null;
|
||||
|
||||
// schedule_event UPDATE
|
||||
await conn.query(
|
||||
`UPDATE schedule_event
|
||||
SET subtype = ?, school_name = ?, venue_id = ?, post_urls = ?
|
||||
WHERE schedule_id = ?`,
|
||||
[
|
||||
subtype,
|
||||
schoolName,
|
||||
venueId,
|
||||
postUrls.length > 0 ? JSON.stringify(postUrls) : null,
|
||||
id,
|
||||
]
|
||||
);
|
||||
|
||||
// 멤버 재등록
|
||||
await conn.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
|
||||
if (memberIds.length > 0) {
|
||||
const values = memberIds.map(mid => [id, mid]);
|
||||
await conn.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
|
||||
}
|
||||
});
|
||||
|
||||
// 포스터: 새 파일 업로드 후 keepPosterIds + 새 id 순서로 저장
|
||||
const newIds = [];
|
||||
for (let i = 0; i < posterFiles.length; i++) {
|
||||
const ext = (posterFiles[i].filename.split('.').pop() || 'webp').toLowerCase();
|
||||
const filename = `${Date.now()}_${i}.${ext === 'jpg' ? 'jpeg' : ext}`;
|
||||
const urls = await uploadEventPoster(id, filename, posterFiles[i].buffer);
|
||||
const imgId = await saveImageRecord(db, urls);
|
||||
newIds.push(imgId);
|
||||
}
|
||||
const finalIds = [...keepPosterIds, ...newIds];
|
||||
await db.query(
|
||||
`UPDATE schedule_event SET poster_image_ids = ? WHERE schedule_id = ?`,
|
||||
[finalIds.length > 0 ? JSON.stringify(finalIds) : null, id]
|
||||
);
|
||||
|
||||
await syncScheduleById(meilisearch, db, parseInt(id));
|
||||
|
||||
logActivity(db, {
|
||||
actor: 'admin', action: 'update', category: 'schedule',
|
||||
targetType: 'event_schedule', targetId: parseInt(id),
|
||||
summary: `행사 수정: ${title}`,
|
||||
});
|
||||
|
||||
return { id: parseInt(id) };
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/events/:id
|
||||
*/
|
||||
fastify.delete('/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
const [existing] = await db.query('SELECT s.title FROM schedules s JOIN schedule_event se ON s.id = se.schedule_id WHERE s.id = ?', [id]);
|
||||
if (existing.length === 0) {
|
||||
return reply.code(404).send({ error: '행사를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// schedules CASCADE로 schedule_event/schedule_members/schedule_images도 정리됨
|
||||
await db.query('DELETE FROM schedules WHERE id = ?', [id]);
|
||||
|
||||
logActivity(db, {
|
||||
actor: 'admin', action: 'delete', category: 'schedule',
|
||||
targetType: 'event_schedule', targetId: parseInt(id),
|
||||
summary: `행사 삭제: ${existing[0].title}`,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
|
@ -14,12 +14,13 @@ const youtubeBotResponse = {
|
|||
channel_handle: { type: 'string' },
|
||||
channel_name: { type: 'string' },
|
||||
banner_url: { type: 'string' },
|
||||
cron_interval: { type: 'integer' },
|
||||
cron_interval: { type: ['integer', 'null'] },
|
||||
enabled: { type: 'boolean' },
|
||||
title_filters: { type: 'array', items: { type: 'string' } },
|
||||
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
||||
extract_members_from_desc: { type: 'boolean' },
|
||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
weekly_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -59,6 +60,11 @@ function formatBotResponse(row) {
|
|||
? JSON.parse(row.auto_schedule_config)
|
||||
: row.auto_schedule_config)
|
||||
: null,
|
||||
weekly_schedule_config: row.weekly_schedule_config
|
||||
? (typeof row.weekly_schedule_config === 'string'
|
||||
? JSON.parse(row.weekly_schedule_config)
|
||||
: row.weekly_schedule_config)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -176,11 +182,12 @@ export default async function youtubeBotsRoutes(fastify) {
|
|||
channel_handle: { type: ['string', 'null'] },
|
||||
channel_name: { type: 'string' },
|
||||
banner_url: { type: ['string', 'null'] },
|
||||
cron_interval: { type: 'integer', default: 2 },
|
||||
cron_interval: { type: ['integer', 'null'] },
|
||||
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
||||
extract_members_from_desc: { type: 'boolean', default: false },
|
||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
weekly_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
},
|
||||
required: ['channel_id', 'channel_name'],
|
||||
},
|
||||
|
|
@ -196,11 +203,12 @@ export default async function youtubeBotsRoutes(fastify) {
|
|||
channel_handle,
|
||||
channel_name,
|
||||
banner_url,
|
||||
cron_interval = 2,
|
||||
cron_interval,
|
||||
title_filters,
|
||||
default_member_ids,
|
||||
extract_members_from_desc = false,
|
||||
auto_schedule_config,
|
||||
weekly_schedule_config,
|
||||
} = request.body;
|
||||
|
||||
// 중복 체크
|
||||
|
|
@ -212,21 +220,26 @@ export default async function youtubeBotsRoutes(fastify) {
|
|||
return badRequest(reply, '이미 등록된 채널입니다.');
|
||||
}
|
||||
|
||||
// weekly 모드면 cron_interval은 무시(null 저장), 아니면 기본값 2
|
||||
const finalCronInterval = weekly_schedule_config ? null : (cron_interval ?? 2);
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO bot_youtube
|
||||
(channel_id, channel_handle, channel_name, banner_url, cron_interval,
|
||||
title_filters, default_member_ids, extract_members_from_desc, auto_schedule_config, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||
title_filters, default_member_ids, extract_members_from_desc,
|
||||
auto_schedule_config, weekly_schedule_config, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||
[
|
||||
channel_id,
|
||||
channel_handle || null,
|
||||
channel_name,
|
||||
banner_url || null,
|
||||
cron_interval,
|
||||
finalCronInterval,
|
||||
title_filters ? JSON.stringify(title_filters) : null,
|
||||
default_member_ids ? JSON.stringify(default_member_ids) : null,
|
||||
extract_members_from_desc ? 1 : 0,
|
||||
auto_schedule_config ? JSON.stringify(auto_schedule_config) : null,
|
||||
weekly_schedule_config ? JSON.stringify(weekly_schedule_config) : null,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -261,11 +274,12 @@ export default async function youtubeBotsRoutes(fastify) {
|
|||
channel_handle: { type: ['string', 'null'] },
|
||||
channel_name: { type: 'string' },
|
||||
banner_url: { type: ['string', 'null'] },
|
||||
cron_interval: { type: 'integer' },
|
||||
cron_interval: { type: ['integer', 'null'] },
|
||||
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
||||
extract_members_from_desc: { type: 'boolean' },
|
||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
weekly_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
enabled: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
|
|
@ -321,6 +335,15 @@ export default async function youtubeBotsRoutes(fastify) {
|
|||
fields.push('auto_schedule_config = ?');
|
||||
values.push(updates.auto_schedule_config ? JSON.stringify(updates.auto_schedule_config) : null);
|
||||
}
|
||||
if (updates.weekly_schedule_config !== undefined) {
|
||||
fields.push('weekly_schedule_config = ?');
|
||||
values.push(updates.weekly_schedule_config ? JSON.stringify(updates.weekly_schedule_config) : null);
|
||||
// weekly 모드로 전환하면 cron_interval은 null, 해제하면 기본값으로 복구(명시 cron_interval이 같이 오지 않은 경우)
|
||||
if (updates.cron_interval === undefined) {
|
||||
fields.push('cron_interval = ?');
|
||||
values.push(updates.weekly_schedule_config ? null : 2);
|
||||
}
|
||||
}
|
||||
if (updates.enabled !== undefined) {
|
||||
fields.push('enabled = ?');
|
||||
values.push(updates.enabled ? 1 : 0);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import xBotsRoutes from './admin/x-bots.js';
|
|||
import youtubeAdminRoutes from './admin/youtube.js';
|
||||
import xAdminRoutes from './admin/x.js';
|
||||
import concertAdminRoutes from './admin/concert.js';
|
||||
import eventsAdminRoutes from './admin/events.js';
|
||||
import varietyAdminRoutes from './admin/variety.js';
|
||||
import placesAdminRoutes from './admin/places.js';
|
||||
import logsAdminRoutes from './admin/logs.js';
|
||||
|
|
@ -51,6 +52,9 @@ export default async function routes(fastify) {
|
|||
// 관리자 - 콘서트 라우트
|
||||
fastify.register(concertAdminRoutes, { prefix: '/admin/concert' });
|
||||
|
||||
// 관리자 - 행사 라우트
|
||||
fastify.register(eventsAdminRoutes, { prefix: '/admin/events' });
|
||||
|
||||
// 관리자 - 예능 라우트
|
||||
fastify.register(varietyAdminRoutes, { prefix: '/admin/variety' });
|
||||
|
||||
|
|
|
|||
|
|
@ -257,6 +257,27 @@ export async function uploadConcertMerchandise(seriesId, filename, buffer) {
|
|||
return { originalUrl, mediumUrl, thumbUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* 행사 포스터 업로드
|
||||
* @param {number} scheduleId - 일정 ID
|
||||
* @param {string} filename - 파일명 (예: '01.webp')
|
||||
* @param {Buffer} buffer - 이미지 버퍼
|
||||
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>}
|
||||
*/
|
||||
export async function uploadEventPoster(scheduleId, filename, buffer) {
|
||||
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
|
||||
|
||||
const basePath = `event/${scheduleId}/poster`;
|
||||
|
||||
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
|
||||
uploadToS3(`${basePath}/original/${filename}`, originalBuffer),
|
||||
uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer),
|
||||
uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer),
|
||||
]);
|
||||
|
||||
return { originalUrl, mediumUrl, thumbUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* 예능 일정 썸네일 업로드
|
||||
* @param {number} scheduleId - 일정 ID
|
||||
|
|
|
|||
|
|
@ -41,6 +41,18 @@ export async function resolveMemberNames(db, query) {
|
|||
return members.map(m => m.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 부분 이름으로 학교명 조회 (예: "인천대" → "인천대학교")
|
||||
*/
|
||||
async function resolveSchoolNames(db, query) {
|
||||
const searchTerm = `%${query}%`;
|
||||
const [rows] = await db.query(
|
||||
`SELECT DISTINCT school_name FROM schedule_event WHERE school_name LIKE ?`,
|
||||
[searchTerm]
|
||||
);
|
||||
return rows.map(r => r.school_name).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 활동 멤버 수 조회 및 캐시
|
||||
*/
|
||||
|
|
@ -97,6 +109,14 @@ export async function searchSchedules(meilisearch, db, query, options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
// 부분 이름 → 전체 학교명 변환 (예: "인천대" → "인천대학교")
|
||||
const schoolNames = await resolveSchoolNames(db, query);
|
||||
for (const name of schoolNames) {
|
||||
if (!searchQueries.includes(name)) {
|
||||
searchQueries.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// 각 검색어로 검색 후 병합
|
||||
const allHits = new Map(); // id 기준 중복 제거
|
||||
|
||||
|
|
@ -160,6 +180,8 @@ function formatScheduleResponse(hit) {
|
|||
source = { name: hit.source_name, url: null };
|
||||
} else if (hit.category_id === CATEGORY_IDS.X) {
|
||||
source = { name: hit.source_name || '', url: null };
|
||||
} else if (hit.category_id === CATEGORY_IDS.EVENT && hit.source_name) {
|
||||
source = { name: hit.source_name, url: null };
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -217,12 +239,13 @@ export async function syncScheduleById(meilisearch, db, scheduleId) {
|
|||
s.category_id,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
COALESCE(sy.channel_name, sx.username) as source_name,
|
||||
COALESCE(sy.channel_name, sx.username, se.school_name) as source_name,
|
||||
GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
LEFT JOIN schedule_event se ON s.id = se.schedule_id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
|
||||
WHERE s.id = ?
|
||||
|
|
@ -291,12 +314,13 @@ export async function syncAllSchedules(meilisearch, db) {
|
|||
s.category_id,
|
||||
c.name as category_name,
|
||||
c.color as category_color,
|
||||
COALESCE(sy.channel_name, sx.username) as source_name,
|
||||
COALESCE(sy.channel_name, sx.username, se.school_name) as source_name,
|
||||
GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
LEFT JOIN schedule_event se ON s.id = se.schedule_id
|
||||
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
||||
LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0
|
||||
GROUP BY s.id
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ export function buildSource(schedule) {
|
|||
};
|
||||
}
|
||||
|
||||
if (category_id === CATEGORY_IDS.EVENT && schedule.event_school_name) {
|
||||
return {
|
||||
name: schedule.event_school_name,
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +98,12 @@ export function formatSchedule(rawSchedule, members = []) {
|
|||
if (rawSchedule.concert_series_id) {
|
||||
result.concertSeriesId = rawSchedule.concert_series_id;
|
||||
}
|
||||
if (rawSchedule.event_subtype) {
|
||||
result.eventSubtype = rawSchedule.event_subtype;
|
||||
if (rawSchedule.event_school_name) {
|
||||
result.schoolName = rawSchedule.event_school_name;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -203,13 +216,25 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
|||
sx.image_urls as x_image_urls,
|
||||
sv.broadcaster as variety_broadcaster,
|
||||
sv.replay_url as variety_replay_url,
|
||||
svi.medium_url as variety_thumbnail_url
|
||||
svi.medium_url as variety_thumbnail_url,
|
||||
se.subtype as event_subtype,
|
||||
se.school_name as event_school_name,
|
||||
se.post_urls as event_post_urls,
|
||||
se.poster_image_ids as event_poster_image_ids,
|
||||
ev.id as event_venue_id,
|
||||
ev.name as event_venue_name,
|
||||
ev.address as event_venue_address,
|
||||
ev.road_address as event_venue_road_address,
|
||||
ev.lat as event_venue_lat,
|
||||
ev.lng as event_venue_lng
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
LEFT JOIN schedule_variety sv ON s.id = sv.schedule_id
|
||||
LEFT JOIN images svi ON sv.thumbnail_id = svi.id
|
||||
LEFT JOIN schedule_event se ON s.id = se.schedule_id
|
||||
LEFT JOIN event_venues ev ON se.venue_id = ev.id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
|
||||
|
|
@ -288,6 +313,43 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
|||
result.broadcaster = s.variety_broadcaster;
|
||||
result.replayUrl = s.variety_replay_url || null;
|
||||
result.thumbnailUrl = s.variety_thumbnail_url || null;
|
||||
} else if (s.category_id === CATEGORY_IDS.EVENT && s.event_subtype) {
|
||||
result.subtype = s.event_subtype;
|
||||
result.schoolName = s.event_school_name || null;
|
||||
result.postUrls = s.event_post_urls
|
||||
? (typeof s.event_post_urls === 'string' ? JSON.parse(s.event_post_urls) : s.event_post_urls)
|
||||
: [];
|
||||
|
||||
const posterIds = s.event_poster_image_ids
|
||||
? (typeof s.event_poster_image_ids === 'string' ? JSON.parse(s.event_poster_image_ids) : s.event_poster_image_ids)
|
||||
: [];
|
||||
if (posterIds.length > 0) {
|
||||
const [posterRows] = await db.query(
|
||||
`SELECT id, original_url, medium_url, thumb_url FROM images WHERE id IN (?) ORDER BY FIELD(id, ?)`,
|
||||
[posterIds, posterIds]
|
||||
);
|
||||
result.posters = posterRows.map(p => ({
|
||||
id: p.id,
|
||||
originalUrl: p.original_url,
|
||||
mediumUrl: p.medium_url,
|
||||
thumbUrl: p.thumb_url,
|
||||
}));
|
||||
} else {
|
||||
result.posters = [];
|
||||
}
|
||||
|
||||
if (s.event_venue_id) {
|
||||
result.venue = {
|
||||
id: s.event_venue_id,
|
||||
name: s.event_venue_name,
|
||||
address: s.event_venue_address,
|
||||
roadAddress: s.event_venue_road_address,
|
||||
lat: s.event_venue_lat,
|
||||
lng: s.event_venue_lng,
|
||||
};
|
||||
} else {
|
||||
result.venue = null;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -310,12 +372,15 @@ const SCHEDULE_LIST_SQL = `
|
|||
sy.video_type as youtube_video_type,
|
||||
sx.post_id as x_post_id,
|
||||
sx.username as x_username,
|
||||
scon.series_id as concert_series_id
|
||||
scon.series_id as concert_series_id,
|
||||
se.subtype as event_subtype,
|
||||
se.school_name as event_school_name
|
||||
FROM schedules s
|
||||
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
||||
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
|
||||
LEFT JOIN schedule_x sx ON s.id = sx.schedule_id
|
||||
LEFT JOIN schedule_concert scon ON s.id = scon.schedule_id
|
||||
LEFT JOIN schedule_event se ON s.id = se.schedule_id
|
||||
`;
|
||||
|
||||
/**
|
||||
|
|
|
|||
67
docs/api.md
67
docs/api.md
|
|
@ -336,10 +336,20 @@ YouTube 봇 추가
|
|||
"time": "18:00:00",
|
||||
"titleTemplate": "{channelName} {episode}화",
|
||||
"deadlineDayOfWeek": 5
|
||||
},
|
||||
"weekly_schedule_config": {
|
||||
"dayOfWeek": 3,
|
||||
"startTime": "19:00",
|
||||
"intervalSeconds": 30,
|
||||
"durationMinutes": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**폴링 방식:**
|
||||
- `cron_interval` (분): 상시 폴링. `weekly_schedule_config`가 null이면 이 값 사용
|
||||
- `weekly_schedule_config`: 지정 요일/시각에만 집중 폴링. 값이 있으면 `cron_interval`은 무시(서버에서 null로 저장). 새 영상 1개 발견 시 즉시 종료(stopOnFound 기본), `durationMinutes` 초과 시에도 종료
|
||||
|
||||
### PUT /admin/youtube-bots/:id
|
||||
YouTube 봇 수정
|
||||
|
||||
|
|
@ -522,6 +532,63 @@ X 일정 저장
|
|||
|
||||
---
|
||||
|
||||
## 관리자 - 행사 (인증 필요)
|
||||
|
||||
### GET /admin/events/:id
|
||||
행사 상세 조회 (수정 폼용)
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"id": 2565,
|
||||
"title": "2026 UNION : PAINT THE UNION🎨",
|
||||
"date": "2026-05-07",
|
||||
"time": "21:30",
|
||||
"subtype": "university",
|
||||
"schoolName": "인천대학교",
|
||||
"memberIds": [1, 2, 3, 4, 5],
|
||||
"venue": {
|
||||
"id": 1,
|
||||
"name": "인천대학교",
|
||||
"address": "...",
|
||||
"roadAddress": "...",
|
||||
"lat": 37.xxx,
|
||||
"lng": 126.xxx,
|
||||
"kakao_id": null
|
||||
},
|
||||
"postUrls": ["https://www.instagram.com/p/..."],
|
||||
"posters": [
|
||||
{ "id": 10001, "originalUrl": "...", "mediumUrl": "...", "thumbUrl": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /admin/events
|
||||
행사 생성 (`multipart/form-data`)
|
||||
|
||||
**multipart 파트:**
|
||||
- `payload` (JSON string): `{ subtype, title, schoolName, date, time, memberIds, venue, postUrls }`
|
||||
- `subtype`: 현재 `'university'`만 지원
|
||||
- `venue`: `{ name, address, roadAddress?, lat, lng, kakao_id? }` — kakao_id 기준으로 event_venues 테이블에 upsert
|
||||
- `title`, `schoolName`, `date`, `venue` 필수
|
||||
- `posters` (파일, 0개 이상): 포스터 이미지. 여러 장 가능
|
||||
|
||||
**응답:** `{ "id": 2565 }`
|
||||
|
||||
### PUT /admin/events/:id
|
||||
행사 수정 (`multipart/form-data`)
|
||||
|
||||
**multipart 파트:**
|
||||
- `payload` (JSON string): 위 POST 필드 + `keepPosterIds: number[]` (유지할 기존 포스터 ID 순서대로)
|
||||
- `posters` (파일, 0개 이상): 새로 추가할 포스터
|
||||
|
||||
서버는 `keepPosterIds` 다음에 새 파일 id들을 이어붙여 `poster_image_ids` 업데이트.
|
||||
|
||||
### DELETE /admin/events/:id
|
||||
행사 삭제 (schedules CASCADE로 schedule_event도 정리)
|
||||
|
||||
---
|
||||
|
||||
## 관리자 - 활동 로그 (인증 필요)
|
||||
|
||||
### GET /admin/logs
|
||||
|
|
|
|||
|
|
@ -326,8 +326,12 @@ fromis_9/
|
|||
- `concert_setlists` - 콘서트 셋리스트
|
||||
- `concert_setlist_members` - 셋리스트-멤버 연결
|
||||
|
||||
#### 행사
|
||||
- `event_venues` - 행사 장소 정보 (카카오맵 기반, 콘서트와 분리)
|
||||
- `schedule_event` - 행사 상세 (subtype, school_name, venue_id, post_urls JSON, poster_image_ids JSON)
|
||||
|
||||
#### 봇
|
||||
- `bot_youtube` - YouTube 봇 설정 (채널 정보, 동기화 간격, 필터 등, video_id UNIQUE)
|
||||
- `bot_youtube` - YouTube 봇 설정 (채널 정보, 동기화 간격 또는 주간 지정 시간, 필터 등, video_id UNIQUE)
|
||||
- `bot_x` - X 봇 설정 (username, 프로필, 동기화 간격, 텍스트 필터, 리트윗 포함, YouTube 추출)
|
||||
|
||||
#### 활동 로그
|
||||
|
|
|
|||
|
|
@ -272,6 +272,27 @@ queryClient.invalidateQueries();
|
|||
- 새 영상 있을 때: 1 + 새 영상 수 units
|
||||
- 1분 간격, 3채널 기준: ~4,320 units/일 (43%)
|
||||
|
||||
### 폴링 모드 (bot_youtube)
|
||||
|
||||
두 가지 모드 중 하나를 선택 — 봇 레코드에 `cron_interval`(분) 또는 `weekly_schedule_config`(JSON) 중 하나가 채워짐.
|
||||
|
||||
**상시 폴링 (기본)**
|
||||
- `cron_interval`이 분 단위로 지정됨. cron: `*/N * * * *`
|
||||
- 매주 여러 날 업로드하는 채널에 적합 (예: `studio_fromis_9`)
|
||||
|
||||
**주간 지정 시간 (weekly)**
|
||||
- `weekly_schedule_config: { dayOfWeek, startTime, intervalSeconds, durationMinutes }`
|
||||
- 주 1회만 특정 요일·시각에 업로드되는 채널용 (예: 워크맨 매주 수 19:00)
|
||||
- cron: `mm hh * * dayOfWeek` — 시작 시각 1회만 트리거
|
||||
- 트리거 시 `startWeeklyBurst()`가 `setInterval`로 `intervalSeconds`마다 폴링
|
||||
- **종료 조건** (둘 중 먼저):
|
||||
1. 새 영상 1개 발견 (stopOnFound, 기본 동작)
|
||||
2. `durationMinutes` 경과
|
||||
- 평상시에는 API 호출 없음 → 할당량 최소화
|
||||
- `burstTimers` Map에서 봇 ID별 내부 타이머 추적, `stopBot()`에서 같이 정리
|
||||
|
||||
두 모드 모두 `MAX_CONSECUTIVE_ERRORS` (기본 10회) 자동 정지 로직이 공통 적용됨.
|
||||
|
||||
### 주요 API 함수 (services/youtube/api.js)
|
||||
| 함수 | YouTube API | 용도 |
|
||||
|------|-----------|------|
|
||||
|
|
@ -283,6 +304,48 @@ queryClient.invalidateQueries();
|
|||
|
||||
---
|
||||
|
||||
## 행사 (Event)
|
||||
|
||||
`schedule_categories`의 "행사" 카테고리(id=11)로 일반 일정과 분리된 상세 테이블(`schedule_event`)을 가짐. 세부 타입(`subtype`)으로 폼/UI를 분기.
|
||||
|
||||
### 세부 타입
|
||||
| slug | label | 현재 사용 필드 |
|
||||
|------|-------|---------------|
|
||||
| `university` | 학교 축제 | `school_name`, venue(카카오맵), 멤버, 포스터 다중, URL 다중 |
|
||||
|
||||
추가 세부 타입을 도입할 때는 1) `frontend/src/pages/pc/admin/schedules/form/event/index.jsx` 의 `SUBTYPES` 상수에 추가, 2) 필요 시 `schedule_event` 컬럼 확장 (또는 `details JSON`), 3) `routes/admin/events.js`의 `VALID_SUBTYPES`, 4) 상세 페이지 섹션(`EventSection`, `MobileEventSection`)에 분기 추가.
|
||||
|
||||
### 장소 관리
|
||||
- `event_venues` 테이블에 `name`/`address`/`road_address`/`lat`/`lng`/`kakao_id` 저장
|
||||
- 카카오맵 검색은 기존 `/api/admin/kakao/places` 엔드포인트 재사용 (콘서트와 동일)
|
||||
- `kakao_id` 기준 upsert — 같은 장소가 여러 행사에서 쓰여도 row는 1개
|
||||
|
||||
### 포스터 업로드 경로
|
||||
S3: `event/{scheduleId}/poster/{original|medium_800|thumb_400}/{파일명}`
|
||||
`services/image.js` 의 `uploadEventPoster(scheduleId, filename, buffer)` 사용.
|
||||
|
||||
### Meilisearch 검색 지원
|
||||
- `source_name`에 `school_name`이 들어가 Meilisearch 검색 가능
|
||||
- 부분 입력 대응: `resolveSchoolNames(db, query)` 가 `schedule_event` 테이블에서 LIKE로 부분 일치 학교명을 찾아 검색 쿼리를 확장 (예: "인천대" → "인천대학교" 쿼리 추가). 멤버 별명 확장과 동일한 패턴.
|
||||
|
||||
---
|
||||
|
||||
## X 봇 / Nitter
|
||||
|
||||
X 봇은 `/docker/nitter/`의 Nitter 인스턴스(zedeus/nitter)를 스크래핑하여 트윗을 수집합니다. 백엔드는 `NITTER_URL`(기본값 `http://nitter:8080`)로 접속합니다.
|
||||
|
||||
### 세션 관리 (`sessions.jsonl`)
|
||||
X는 비로그인 API 접근을 막고 있어, Nitter는 `/docker/nitter/sessions.jsonl`에 저장된 실제 X 계정 쿠키(`auth_token`, `ct0`)로 요청을 보냅니다.
|
||||
|
||||
- 세션이 만료/차단되면 Nitter 측에서 `no sessions available for API` 로그가 찍히고 SIGSEGV로 크래시 → 백엔드에서 `[x-N] 동기화 오류: 요청 타임아웃` 반복 (단, 연속 10회 실패 시 자동 정지 — `logs.md` 참조)
|
||||
- `renew_sessions.py`가 매시 세션을 점검하지만, 판별 기준(`check_nitter()`)이 약하면 만료 상태에서도 "정상"으로 오판할 수 있음 → 기준은 트윗 본문(`tweet-content` 블록) 렌더 여부로 유지할 것
|
||||
- 수동 갱신: `python3 /docker/nitter/create_session_curl.py <username> <password>` 로 새 쿠키 발급 후 `sessions.jsonl` 두 줄을 덮어쓰고 `docker compose restart nitter` 실행
|
||||
|
||||
### 포크 관련 메모
|
||||
`unixfox/nitter` 같은 구버전 기반 포크는 sessions.jsonl을 아예 인식하지 못해 트윗 수집이 불가능합니다. 교체 시에는 바이너리에 sessions 처리 심볼이 있는지 확인할 것(예: `strings nitter | grep sessions.jsonl`).
|
||||
|
||||
---
|
||||
|
||||
## 활동 로그 시스템
|
||||
|
||||
관리자/봇의 모든 활동을 `logs` 테이블에 기록하고 관리자 페이지에서 조회.
|
||||
|
|
|
|||
|
|
@ -100,6 +100,15 @@ logActivity(db, { actor, action, category, targetType, targetId, summary, detail
|
|||
|
||||
> **봇 로그 전략:** 변화 없는 동기화는 로그 안 남김. `addedCount > 0`이거나 에러인 경우만 기록.
|
||||
|
||||
### 연속 오류 시 자동 정지
|
||||
|
||||
`plugins/scheduler.js`의 `MAX_CONSECUTIVE_ERRORS`(기본 10)로 제어.
|
||||
|
||||
- Redis의 bot status에 `consecutiveErrors` 카운터를 유지. 성공 시 0으로 리셋, 실패 시 +1.
|
||||
- 동일한 에러 루프에서 `sync/error` 로그는 **첫 1회만** 기록 (로그 테이블 스팸 방지).
|
||||
- 카운터가 `MAX_CONSECUTIVE_ERRORS`에 도달하면 `stopBot()`을 호출해 cron 태스크를 내리고, `bot/stop` action으로 *"${botId} 연속 N회 실패로 자동 정지"* 로그 1건을 남김.
|
||||
- 자동 정지된 봇은 원인 조치 후 관리자 UI에서 수동으로 다시 시작해야 함.
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 구현
|
||||
|
|
|
|||
33
frontend/src/api/admin/events.js
Normal file
33
frontend/src/api/admin/events.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* 관리자 행사 API
|
||||
*/
|
||||
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
||||
|
||||
/**
|
||||
* 행사 상세 조회 (수정 폼용)
|
||||
*/
|
||||
export async function getEvent(id) {
|
||||
return fetchAuthApi(`/admin/events/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 행사 생성
|
||||
* @param {FormData} formData - payload(JSON) + poster 파일들
|
||||
*/
|
||||
export async function createEvent(formData) {
|
||||
return fetchFormData('/admin/events', formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* 행사 수정
|
||||
*/
|
||||
export async function updateEvent(id, formData) {
|
||||
return fetchFormData(`/admin/events/${id}`, formData, 'PUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* 행사 삭제
|
||||
*/
|
||||
export async function deleteEvent(id) {
|
||||
return fetchAuthApi(`/admin/events/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export * as adminBotApi from './bots';
|
|||
export * as adminStatsApi from './stats';
|
||||
export * as adminSuggestionApi from './suggestions';
|
||||
export * as adminLogApi from './logs';
|
||||
export * as adminEventApi from './events';
|
||||
export * as adminAuthApi from './auth';
|
||||
|
||||
// 개별 함수 export
|
||||
|
|
|
|||
76
frontend/src/components/common/KakaoMap.jsx
Normal file
76
frontend/src/components/common/KakaoMap.jsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const KAKAO_JS_KEY = import.meta.env.VITE_KAKAO_JS_KEY;
|
||||
|
||||
let kakaoLoadPromise = null;
|
||||
|
||||
/**
|
||||
* Kakao Maps JavaScript SDK 1회 로드 (애플리케이션 전체에서 공유)
|
||||
*/
|
||||
function loadKakaoSdk() {
|
||||
if (typeof window === 'undefined') return Promise.reject(new Error('window unavailable'));
|
||||
if (window.kakao?.maps) return Promise.resolve(window.kakao);
|
||||
if (kakaoLoadPromise) return kakaoLoadPromise;
|
||||
|
||||
kakaoLoadPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${KAKAO_JS_KEY}&autoload=false`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
if (!window.kakao?.maps) {
|
||||
reject(new Error('Kakao SDK loaded but window.kakao.maps missing'));
|
||||
return;
|
||||
}
|
||||
window.kakao.maps.load(() => resolve(window.kakao));
|
||||
};
|
||||
script.onerror = () => reject(new Error('Failed to load Kakao Maps SDK'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return kakaoLoadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kakao 지도 + 마커 렌더링
|
||||
*
|
||||
* @param {number} lat
|
||||
* @param {number} lng
|
||||
* @param {string} name - 마커에 표시할 이름 (선택)
|
||||
* @param {string} className - 컨테이너 클래스
|
||||
* @param {number} level - 지도 확대 레벨 (기본 3)
|
||||
*/
|
||||
function KakaoMap({ lat, lng, name, className, level = 3 }) {
|
||||
const containerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lat || !lng || !containerRef.current) return;
|
||||
let cancelled = false;
|
||||
|
||||
loadKakaoSdk()
|
||||
.then((kakao) => {
|
||||
if (cancelled || !containerRef.current) return;
|
||||
const center = new kakao.maps.LatLng(lat, lng);
|
||||
const map = new kakao.maps.Map(containerRef.current, { center, level });
|
||||
const marker = new kakao.maps.Marker({ position: center, map });
|
||||
if (name) {
|
||||
const overlay = new kakao.maps.CustomOverlay({
|
||||
position: center,
|
||||
yAnchor: 2.2,
|
||||
content: `<div style="padding:4px 10px;background:#fff;border:1px solid #e5e7eb;border-radius:9999px;font-size:12px;color:#374151;white-space:nowrap;box-shadow:0 1px 2px rgba(0,0,0,0.05);">${name}</div>`,
|
||||
});
|
||||
overlay.setMap(map);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('KakaoMap 로드 실패:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [lat, lng, name, level]);
|
||||
|
||||
return <div ref={containerRef} className={className} />;
|
||||
}
|
||||
|
||||
export default KakaoMap;
|
||||
|
|
@ -6,6 +6,7 @@ export { default as ScrollToTop } from './ScrollToTop';
|
|||
export { default as Lightbox } from './Lightbox';
|
||||
export { default as MobileLightbox } from './MobileLightbox';
|
||||
export { default as LightboxIndicator } from './LightboxIndicator';
|
||||
export { default as KakaoMap } from './KakaoMap';
|
||||
export { default as AnimatedNumber } from './AnimatedNumber';
|
||||
export { default as Fromis9Logo } from './Fromis9Logo';
|
||||
export { default as DebutCelebrationDialog } from './DebutCelebrationDialog';
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Youtube, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-reac
|
|||
import { getMembers } from '@/api/public/members';
|
||||
import { getYouTubeBot, createYouTubeBot, updateYouTubeBot, lookupChannel } from '@/api/admin/bots';
|
||||
|
||||
// 동기화 간격 옵션
|
||||
// 동기화 간격 옵션 (분)
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 1, label: '1분' },
|
||||
{ value: 2, label: '2분' },
|
||||
|
|
@ -19,15 +19,32 @@ const INTERVAL_OPTIONS = [
|
|||
{ value: 60, label: '1시간' },
|
||||
];
|
||||
|
||||
// 요일 옵션
|
||||
// weekly 모드 폴링 간격 옵션 (초)
|
||||
const WEEKLY_INTERVAL_OPTIONS = [
|
||||
{ value: 10, label: '10초' },
|
||||
{ value: 30, label: '30초' },
|
||||
{ value: 60, label: '1분' },
|
||||
{ value: 120, label: '2분' },
|
||||
{ value: 300, label: '5분' },
|
||||
];
|
||||
|
||||
// weekly 모드 지속 시간 옵션 (분)
|
||||
const WEEKLY_DURATION_OPTIONS = [
|
||||
{ value: 10, label: '10분' },
|
||||
{ value: 30, label: '30분' },
|
||||
{ value: 60, label: '1시간' },
|
||||
{ value: 120, label: '2시간' },
|
||||
];
|
||||
|
||||
// 요일 옵션 (월~일 순서 표시, value는 cron 표준 0=일 ~ 6=토 유지)
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 0, label: '일요일' },
|
||||
{ value: 1, label: '월요일' },
|
||||
{ value: 2, label: '화요일' },
|
||||
{ value: 3, label: '수요일' },
|
||||
{ value: 4, label: '목요일' },
|
||||
{ value: 5, label: '금요일' },
|
||||
{ value: 6, label: '토요일' },
|
||||
{ value: 0, label: '일요일' },
|
||||
];
|
||||
|
||||
// 시간 옵션 (00:00 ~ 23:00)
|
||||
|
|
@ -262,7 +279,12 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
const [handle, setHandle] = useState('');
|
||||
const [channelInfo, setChannelInfo] = useState(null);
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [pollingMode, setPollingMode] = useState('interval'); // 'interval' | 'weekly'
|
||||
const [interval, setInterval] = useState(2);
|
||||
const [weeklyDayOfWeek, setWeeklyDayOfWeek] = useState(1); // 기본 월요일
|
||||
const [weeklyStartTime, setWeeklyStartTime] = useState('00:00');
|
||||
const [weeklyIntervalSeconds, setWeeklyIntervalSeconds] = useState(30);
|
||||
const [weeklyDurationMinutes, setWeeklyDurationMinutes] = useState(30);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// 예정 일정 설정
|
||||
|
|
@ -315,6 +337,26 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
});
|
||||
setInterval(bot.cron_interval || 2);
|
||||
|
||||
// 폴링 모드 판별: weekly_schedule_config가 있으면 weekly
|
||||
const weeklyCfg = bot.weekly_schedule_config
|
||||
? (typeof bot.weekly_schedule_config === 'string'
|
||||
? JSON.parse(bot.weekly_schedule_config)
|
||||
: bot.weekly_schedule_config)
|
||||
: null;
|
||||
if (weeklyCfg) {
|
||||
setPollingMode('weekly');
|
||||
setWeeklyDayOfWeek(weeklyCfg.dayOfWeek ?? 1);
|
||||
setWeeklyStartTime(weeklyCfg.startTime || '00:00');
|
||||
setWeeklyIntervalSeconds(weeklyCfg.intervalSeconds ?? 30);
|
||||
setWeeklyDurationMinutes(weeklyCfg.durationMinutes ?? 30);
|
||||
} else {
|
||||
setPollingMode('interval');
|
||||
setWeeklyDayOfWeek(1);
|
||||
setWeeklyStartTime('00:00');
|
||||
setWeeklyIntervalSeconds(30);
|
||||
setWeeklyDurationMinutes(30);
|
||||
}
|
||||
|
||||
const config = bot.auto_schedule_config
|
||||
? (typeof bot.auto_schedule_config === 'string'
|
||||
? JSON.parse(bot.auto_schedule_config)
|
||||
|
|
@ -353,6 +395,11 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
setHandle('');
|
||||
setChannelInfo(null);
|
||||
setInterval(2);
|
||||
setPollingMode('interval');
|
||||
setWeeklyDayOfWeek(1);
|
||||
setWeeklyStartTime('00:00');
|
||||
setWeeklyIntervalSeconds(30);
|
||||
setWeeklyDurationMinutes(30);
|
||||
setAutoScheduleEnabled(false);
|
||||
setScheduleDayOfWeek(4);
|
||||
setScheduleTime('18:00');
|
||||
|
|
@ -396,7 +443,7 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
const data = {
|
||||
channel_handle: handle || null,
|
||||
channel_name: channelInfo.title,
|
||||
cron_interval: interval,
|
||||
cron_interval: pollingMode === 'interval' ? interval : null,
|
||||
title_filters: titleFilters.length > 0 ? titleFilters : null,
|
||||
default_member_ids: defaultMemberIds.length > 0 ? defaultMemberIds : null,
|
||||
extract_members_from_desc: extractMembers,
|
||||
|
|
@ -408,6 +455,14 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
deadlineDayOfWeek,
|
||||
}
|
||||
: null,
|
||||
weekly_schedule_config: pollingMode === 'weekly'
|
||||
? {
|
||||
dayOfWeek: weeklyDayOfWeek,
|
||||
startTime: weeklyStartTime,
|
||||
intervalSeconds: weeklyIntervalSeconds,
|
||||
durationMinutes: weeklyDurationMinutes,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
|
|
@ -530,17 +585,95 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 동기화 간격 */}
|
||||
{/* 동기화 모드 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
동기화 간격
|
||||
동기화 방식
|
||||
</label>
|
||||
<Dropdown
|
||||
value={interval}
|
||||
options={INTERVAL_OPTIONS}
|
||||
onChange={setInterval}
|
||||
placeholder="간격 선택"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPollingMode('interval')}
|
||||
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
pollingMode === 'interval'
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
상시 폴링
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPollingMode('weekly')}
|
||||
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
pollingMode === 'weekly'
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
주간 지정 시간
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pollingMode === 'interval' ? (
|
||||
<div>
|
||||
<Dropdown
|
||||
value={interval}
|
||||
options={INTERVAL_OPTIONS}
|
||||
onChange={setInterval}
|
||||
placeholder="간격 선택"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
선택한 간격으로 계속 체크합니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">요일</label>
|
||||
<Dropdown
|
||||
value={weeklyDayOfWeek}
|
||||
options={DAY_OPTIONS}
|
||||
onChange={setWeeklyDayOfWeek}
|
||||
placeholder="요일 선택"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">시작 시각</label>
|
||||
<Dropdown
|
||||
value={weeklyStartTime}
|
||||
options={TIME_OPTIONS}
|
||||
onChange={setWeeklyStartTime}
|
||||
placeholder="시간 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">폴링 간격</label>
|
||||
<Dropdown
|
||||
value={weeklyIntervalSeconds}
|
||||
options={WEEKLY_INTERVAL_OPTIONS}
|
||||
onChange={setWeeklyIntervalSeconds}
|
||||
placeholder="간격 선택"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">최대 지속</label>
|
||||
<Dropdown
|
||||
value={weeklyDurationMinutes}
|
||||
options={WEEKLY_DURATION_OPTIONS}
|
||||
onChange={setWeeklyDurationMinutes}
|
||||
placeholder="지속 시간"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
지정된 요일·시각부터 이 간격으로 폴링합니다. 새 영상 발견 시 즉시 종료하며, 최대 지속시간 초과 시에도 종료합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 예정 일정 자동 생성 */}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export const getEditPath = (scheduleId, categoryName, schedule) => {
|
|||
return `/admin/schedule/${scheduleId}/edit`;
|
||||
case '예능':
|
||||
return `/admin/schedule/${scheduleId}/edit/variety`;
|
||||
case '행사':
|
||||
return `/admin/schedule/${scheduleId}/edit/event`;
|
||||
default:
|
||||
return `/admin/schedule/${scheduleId}/edit`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { useParams, Link } from 'react-router-dom';
|
|||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, Tv, ExternalLink, Play } from 'lucide-react';
|
||||
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, Tv, ExternalLink, Play, MapPin, GraduationCap } from 'lucide-react';
|
||||
import { getSchedule } from '@/api';
|
||||
import { KakaoMap } from '@/components/common';
|
||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
||||
import Birthday from './Birthday';
|
||||
|
||||
|
|
@ -475,6 +476,156 @@ function MobileXSection({ schedule }) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile 행사 섹션 (학교 행사 등)
|
||||
*/
|
||||
function MobileEventSection({ schedule }) {
|
||||
const members = schedule.members || [];
|
||||
const isFullGroup = members.length === 5;
|
||||
const posters = schedule.posters || [];
|
||||
const postUrls = schedule.postUrls || [];
|
||||
const venue = schedule.venue || null;
|
||||
const categoryColor = schedule.category?.color || '#facc15';
|
||||
const kakaoMapUrl = venue && venue.lat && venue.lng
|
||||
? `https://map.kakao.com/link/map/${encodeURIComponent(venue.name)},${venue.lat},${venue.lng}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 포스터 */}
|
||||
{posters.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-xl overflow-hidden shadow-sm bg-white">
|
||||
<img
|
||||
src={posters[0].mediumUrl || posters[0].originalUrl}
|
||||
alt={schedule.title}
|
||||
className="w-full h-auto object-cover"
|
||||
/>
|
||||
</div>
|
||||
{posters.length > 1 && (
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{posters.slice(1).map((p) => (
|
||||
<a
|
||||
key={p.id}
|
||||
href={p.originalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block aspect-square rounded-md overflow-hidden border border-gray-100"
|
||||
>
|
||||
<img src={p.thumbUrl || p.mediumUrl} alt="" className="w-full h-full object-cover" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="w-full aspect-[3/4] rounded-xl flex items-center justify-center"
|
||||
style={{ backgroundColor: `${categoryColor}10` }}
|
||||
>
|
||||
<GraduationCap size={48} style={{ color: categoryColor }} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 정보 카드 */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
{schedule.schoolName && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-semibold rounded-md"
|
||||
style={{ backgroundColor: `${categoryColor}25`, color: '#92400e' }}
|
||||
>
|
||||
<GraduationCap size={10} />
|
||||
{schedule.schoolName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatFullDate(schedule.date)}
|
||||
{schedule.time && ` · ${formatTime(schedule.time)}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-bold text-gray-900 text-base leading-snug mb-3">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h1>
|
||||
|
||||
{members.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{isFullGroup ? (
|
||||
<span className="px-2.5 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
프로미스나인
|
||||
</span>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<span key={member.id} className="px-2.5 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
{member.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{venue && (
|
||||
<div className="pt-3 border-t border-gray-100 mb-3">
|
||||
<div className="flex items-start gap-2 mb-2.5">
|
||||
<MapPin size={14} className="text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900">{venue.name}</p>
|
||||
{venue.address && (
|
||||
<p className="text-xs text-gray-500">{venue.address}</p>
|
||||
)}
|
||||
{kakaoMapUrl && (
|
||||
<a
|
||||
href={kakaoMapUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-1 text-xs text-primary"
|
||||
>
|
||||
카카오맵에서 보기
|
||||
<ExternalLink size={10} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{venue.lat && venue.lng && (
|
||||
<KakaoMap
|
||||
lat={Number(venue.lat)}
|
||||
lng={Number(venue.lng)}
|
||||
name={venue.name}
|
||||
className="w-full h-40 rounded-lg overflow-hidden border border-gray-100"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{postUrls.length > 0 && (
|
||||
<div className="pt-3 border-t border-gray-100">
|
||||
<p className="flex items-center gap-1 text-xs font-medium text-gray-700 mb-1.5">
|
||||
<Link2 size={12} />
|
||||
관련 링크
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{postUrls.map((url, idx) => (
|
||||
<li key={idx} className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-gray-300 select-none">·</span>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary truncate"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile 예능 섹션
|
||||
*/
|
||||
|
|
@ -733,6 +884,8 @@ function MobileScheduleDetail() {
|
|||
return <MobileXSection schedule={schedule} />;
|
||||
case '예능':
|
||||
return <MobileVarietySection schedule={schedule} />;
|
||||
case '행사':
|
||||
return <MobileEventSection schedule={schedule} />;
|
||||
default:
|
||||
return <MobileDefaultSection schedule={schedule} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ function Logs() {
|
|||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageInput, setPageInput] = useState('1');
|
||||
const [actorDropdownOpen, setActorDropdownOpen] = useState(false);
|
||||
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState(null);
|
||||
|
|
@ -65,6 +66,20 @@ function Logs() {
|
|||
const total = data?.total || 0;
|
||||
const totalPages = data?.totalPages || 0;
|
||||
|
||||
// 페이지 변경 시 입력 필드 동기화
|
||||
useEffect(() => { setPageInput(String(currentPage)); }, [currentPage]);
|
||||
|
||||
const goToPageFromInput = () => {
|
||||
const n = parseInt(pageInput, 10);
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
setPageInput(String(currentPage));
|
||||
return;
|
||||
}
|
||||
const clamped = Math.min(totalPages, n);
|
||||
setCurrentPage(clamped);
|
||||
setPageInput(String(clamped));
|
||||
};
|
||||
|
||||
// 카테고리 토글
|
||||
const toggleCategory = (cat) => {
|
||||
setSelectedCategories((prev) =>
|
||||
|
|
@ -343,52 +358,74 @@ function Logs() {
|
|||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter((page) => {
|
||||
if (totalPages <= 7) return true;
|
||||
if (page === 1 || page === totalPages) return true;
|
||||
if (Math.abs(page - currentPage) <= 2) return true;
|
||||
return false;
|
||||
})
|
||||
.reduce((acc, page, i, arr) => {
|
||||
if (i > 0 && page - arr[i - 1] > 1) {
|
||||
acc.push({ type: 'ellipsis', key: `e-${page}` });
|
||||
}
|
||||
acc.push({ type: 'page', value: page, key: page });
|
||||
return acc;
|
||||
}, [])
|
||||
.map((item) =>
|
||||
item.type === 'ellipsis' ? (
|
||||
<span key={item.key} className="w-9 h-9 flex items-center justify-center text-sm text-gray-400">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setCurrentPage(item.value)}
|
||||
className={`w-9 h-9 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentPage === item.value
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{item.value}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
<div className="grid grid-cols-3 items-center mt-6">
|
||||
<div />
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter((page) => {
|
||||
if (totalPages <= 7) return true;
|
||||
if (page === 1 || page === totalPages) return true;
|
||||
if (Math.abs(page - currentPage) <= 2) return true;
|
||||
return false;
|
||||
})
|
||||
.reduce((acc, page, i, arr) => {
|
||||
if (i > 0 && page - arr[i - 1] > 1) {
|
||||
acc.push({ type: 'ellipsis', key: `e-${page}` });
|
||||
}
|
||||
acc.push({ type: 'page', value: page, key: page });
|
||||
return acc;
|
||||
}, [])
|
||||
.map((item) =>
|
||||
item.type === 'ellipsis' ? (
|
||||
<span key={item.key} className="w-9 h-9 flex items-center justify-center text-sm text-gray-400">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setCurrentPage(item.value)}
|
||||
className={`w-9 h-9 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentPage === item.value
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{item.value}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={pageInput}
|
||||
onChange={(e) => setPageInput(e.target.value.replace(/\D/g, ''))}
|
||||
onBlur={goToPageFromInput}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
goToPageFromInput();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="w-12 h-9 text-center tabular-nums border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
aria-label="페이지 번호 입력"
|
||||
/>
|
||||
<span className="text-sm text-gray-400 tabular-nums">/ {totalPages}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
448
frontend/src/pages/pc/admin/schedules/edit/EventEditForm.jsx
Normal file
448
frontend/src/pages/pc/admin/schedules/edit/EventEditForm.jsx
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Save, Loader2, GraduationCap, MapPin, Link2, Image as ImageIcon, Users, X,
|
||||
} from "lucide-react";
|
||||
|
||||
import AdminLayout from "@/components/pc/admin/layout/Layout";
|
||||
import Toast from "@/components/common/Toast";
|
||||
import DatePicker from "@/components/pc/admin/common/DatePicker";
|
||||
import TimePicker from "@/components/pc/admin/common/TimePicker";
|
||||
import LocationSearchDialog from "@/components/pc/admin/schedule/LocationSearchDialog";
|
||||
import { useToast } from "@/hooks/common";
|
||||
import { useAdminAuth } from "@/hooks/pc/admin";
|
||||
import { getMembers } from "@/api/public/members";
|
||||
import { getEvent, updateEvent } from "@/api/admin/events";
|
||||
|
||||
const SUBTYPES = [
|
||||
{ value: "university", label: "학교 축제" },
|
||||
];
|
||||
|
||||
function EventEditForm() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { toast, setToast } = useToast();
|
||||
const { isAuthenticated } = useAdminAuth();
|
||||
|
||||
const { data: membersData = [] } = useQuery({
|
||||
queryKey: ["members"],
|
||||
queryFn: getMembers,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const members = membersData.filter((m) => !m.is_former);
|
||||
|
||||
const { data: eventData, isLoading } = useQuery({
|
||||
queryKey: ["event-schedule", id],
|
||||
queryFn: () => getEvent(id),
|
||||
enabled: isAuthenticated && !!id,
|
||||
});
|
||||
|
||||
const [subtype, setSubtype] = useState("university");
|
||||
const [title, setTitle] = useState("");
|
||||
const [schoolName, setSchoolName] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("");
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
||||
const [venue, setVenue] = useState(null);
|
||||
const [venueDialogOpen, setVenueDialogOpen] = useState(false);
|
||||
const [existingPosters, setExistingPosters] = useState([]); // [{id, mediumUrl}]
|
||||
const [keepPosterIds, setKeepPosterIds] = useState([]);
|
||||
const [newPosterFiles, setNewPosterFiles] = useState([]); // [{file, preview}]
|
||||
const [postUrls, setPostUrls] = useState([]);
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventData && !initialized) {
|
||||
setSubtype(eventData.subtype || "university");
|
||||
setTitle(eventData.title || "");
|
||||
setSchoolName(eventData.schoolName || "");
|
||||
setDate(eventData.date || "");
|
||||
setTime(eventData.time || "");
|
||||
setSelectedMemberIds(eventData.memberIds || []);
|
||||
setVenue(eventData.venue || null);
|
||||
setExistingPosters(eventData.posters || []);
|
||||
setKeepPosterIds((eventData.posters || []).map((p) => p.id));
|
||||
setPostUrls(eventData.postUrls || []);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [eventData, initialized]);
|
||||
|
||||
const toggleMember = (memberId) => {
|
||||
setSelectedMemberIds((prev) =>
|
||||
prev.includes(memberId) ? prev.filter((i) => i !== memberId) : [...prev, memberId]
|
||||
);
|
||||
};
|
||||
const toggleAllMembers = () => {
|
||||
setSelectedMemberIds(
|
||||
selectedMemberIds.length === members.length ? [] : members.map((m) => m.id)
|
||||
);
|
||||
};
|
||||
|
||||
// 포스터
|
||||
const handlePosterChange = (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const newItems = files.map((file) => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve({ file, preview: reader.result });
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
});
|
||||
Promise.all(newItems).then((items) => {
|
||||
setNewPosterFiles((prev) => [...prev, ...items]);
|
||||
});
|
||||
e.target.value = "";
|
||||
};
|
||||
const removeExistingPoster = (posterId) => {
|
||||
setKeepPosterIds((prev) => prev.filter((id) => id !== posterId));
|
||||
setExistingPosters((prev) => prev.filter((p) => p.id !== posterId));
|
||||
};
|
||||
const removeNewPoster = (index) => {
|
||||
setNewPosterFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// URL
|
||||
const addUrl = () => {
|
||||
const url = urlInput.trim();
|
||||
if (!url) return;
|
||||
if (!postUrls.includes(url)) setPostUrls([...postUrls, url]);
|
||||
setUrlInput("");
|
||||
};
|
||||
const removeUrl = (index) => {
|
||||
setPostUrls(postUrls.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim() || !schoolName.trim() || !date || !venue) {
|
||||
setToast({ type: "error", message: "필수 항목을 입력해주세요." });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
subtype,
|
||||
title: title.trim(),
|
||||
schoolName: schoolName.trim(),
|
||||
date,
|
||||
time: time || null,
|
||||
memberIds: selectedMemberIds,
|
||||
venue,
|
||||
postUrls,
|
||||
keepPosterIds,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("payload", JSON.stringify(payload));
|
||||
newPosterFiles.forEach((item) => {
|
||||
formData.append("posters", item.file);
|
||||
});
|
||||
|
||||
await updateEvent(id, formData);
|
||||
sessionStorage.setItem(
|
||||
"scheduleToast",
|
||||
JSON.stringify({ type: "success", message: "행사 일정이 수정되었습니다." })
|
||||
);
|
||||
navigate("/admin/schedule");
|
||||
} catch (err) {
|
||||
setToast({ type: "error", message: err.message || "수정에 실패했습니다." });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 max-w-4xl mx-auto px-6 py-8"
|
||||
>
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">기본 정보</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">세부 타입</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SUBTYPES.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSubtype(opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm border transition-colors ${
|
||||
subtype === opt.value
|
||||
? "border-primary bg-primary text-white"
|
||||
: "border-gray-200 text-gray-600 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">제목 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
|
||||
<GraduationCap size={14} />
|
||||
학교명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={schoolName}
|
||||
onChange={(e) => setSchoolName(e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-1.5 block">날짜 *</label>
|
||||
<DatePicker value={date} onChange={setDate} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-1.5 block">시간 (선택)</label>
|
||||
<TimePicker value={time} onChange={setTime} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
|
||||
<MapPin size={14} />
|
||||
장소 *
|
||||
</label>
|
||||
{venue ? (
|
||||
<div className="flex items-start gap-3 p-3 border border-gray-200 rounded-lg">
|
||||
<MapPin size={16} className="text-primary mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900">{venue.name}</p>
|
||||
{venue.address && (
|
||||
<p className="text-sm text-gray-500 truncate">{venue.address}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVenueDialogOpen(true)}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
변경
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVenue(null)}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVenueDialogOpen(true)}
|
||||
className="w-full px-3 py-2.5 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
장소 검색
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출연 멤버 */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-6">
|
||||
<Users size={18} />
|
||||
출연 멤버
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllMembers}
|
||||
className={`px-4 py-1.5 rounded-full border text-sm transition-colors ${
|
||||
selectedMemberIds.length === members.length
|
||||
? "border-primary bg-primary text-white"
|
||||
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selectedMemberIds.length === members.length ? "전체 해제" : "전체 선택"}
|
||||
</button>
|
||||
{members.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => toggleMember(m.id)}
|
||||
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
|
||||
selectedMemberIds.includes(m.id) ? "border-primary" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
|
||||
{m.image_url ? (
|
||||
<img src={m.image_url} alt={m.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-700">{m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 포스터 */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-4">
|
||||
<ImageIcon size={18} />
|
||||
포스터 <span className="text-sm font-normal text-gray-400">(선택, 여러 장 가능)</span>
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{existingPosters.map((p) => (
|
||||
<div key={`e-${p.id}`} className="relative">
|
||||
<img src={p.mediumUrl || p.thumbUrl} alt={`poster ${p.id}`} className="h-32 w-32 object-cover rounded-lg border border-gray-200" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeExistingPoster(p.id)}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs flex items-center justify-center hover:bg-red-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{newPosterFiles.map((item, idx) => (
|
||||
<div key={`n-${idx}`} className="relative">
|
||||
<img src={item.preview} alt={`new poster ${idx}`} className="h-32 w-32 object-cover rounded-lg border border-gray-200" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeNewPoster(idx)}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs flex items-center justify-center hover:bg-red-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label className="flex items-center justify-center h-32 w-32 border-2 border-dashed border-gray-200 rounded-lg cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors">
|
||||
<div className="text-center">
|
||||
<ImageIcon size={20} className="mx-auto text-gray-400 mb-1" />
|
||||
<span className="text-xs text-gray-400">추가</span>
|
||||
</div>
|
||||
<input type="file" accept="image/*" multiple className="hidden" onChange={handlePosterChange} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-4">
|
||||
<Link2 size={18} />
|
||||
관련 URL <span className="text-sm font-normal text-gray-400">(선택, 여러 개 가능)</span>
|
||||
</h2>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="url"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addUrl();
|
||||
}
|
||||
}}
|
||||
placeholder="https://..."
|
||||
className="flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addUrl}
|
||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
{postUrls.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{postUrls.map((url, idx) => (
|
||||
<li key={idx} className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-lg">
|
||||
<a href={url} target="_blank" rel="noreferrer" className="flex-1 truncate text-sm text-gray-600 hover:underline">
|
||||
{url}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeUrl(idx)}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/admin/schedule")}
|
||||
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
수정 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
수정
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
|
||||
<LocationSearchDialog
|
||||
isOpen={venueDialogOpen}
|
||||
onClose={() => setVenueDialogOpen(false)}
|
||||
onSelect={(place) => setVenue(place)}
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventEditForm;
|
||||
435
frontend/src/pages/pc/admin/schedules/form/event/index.jsx
Normal file
435
frontend/src/pages/pc/admin/schedules/form/event/index.jsx
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Save, GraduationCap, MapPin, Link2, Image as ImageIcon, Users, X,
|
||||
} from "lucide-react";
|
||||
|
||||
import Toast from "@/components/common/Toast";
|
||||
import DatePicker from "@/components/pc/admin/common/DatePicker";
|
||||
import TimePicker from "@/components/pc/admin/common/TimePicker";
|
||||
import LocationSearchDialog from "@/components/pc/admin/schedule/LocationSearchDialog";
|
||||
import { useToast } from "@/hooks/common";
|
||||
import { useAdminAuth } from "@/hooks/pc/admin";
|
||||
import { getMembers } from "@/api/public/members";
|
||||
import { createEvent } from "@/api/admin/events";
|
||||
|
||||
// 세부 타입 목록 (현재는 "학교"만)
|
||||
const SUBTYPES = [
|
||||
{ value: "university", label: "학교 축제" },
|
||||
];
|
||||
|
||||
function EventForm() {
|
||||
const navigate = useNavigate();
|
||||
const { toast, setToast } = useToast();
|
||||
const { isAuthenticated } = useAdminAuth();
|
||||
|
||||
// 멤버 목록
|
||||
const { data: membersData = [] } = useQuery({
|
||||
queryKey: ["members"],
|
||||
queryFn: getMembers,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const members = membersData.filter((m) => !m.is_former);
|
||||
|
||||
// 공통 상태
|
||||
const [subtype, setSubtype] = useState("university");
|
||||
const [title, setTitle] = useState("");
|
||||
const [schoolName, setSchoolName] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("");
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
||||
const [venue, setVenue] = useState(null);
|
||||
const [venueDialogOpen, setVenueDialogOpen] = useState(false);
|
||||
const [posterFiles, setPosterFiles] = useState([]); // [{file, preview}]
|
||||
const [postUrls, setPostUrls] = useState([]);
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 멤버 토글
|
||||
const toggleMember = (memberId) => {
|
||||
setSelectedMemberIds((prev) =>
|
||||
prev.includes(memberId)
|
||||
? prev.filter((id) => id !== memberId)
|
||||
: [...prev, memberId]
|
||||
);
|
||||
};
|
||||
const toggleAllMembers = () => {
|
||||
if (selectedMemberIds.length === members.length) {
|
||||
setSelectedMemberIds([]);
|
||||
} else {
|
||||
setSelectedMemberIds(members.map((m) => m.id));
|
||||
}
|
||||
};
|
||||
|
||||
// 포스터 파일 추가
|
||||
const handlePosterChange = (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const newItems = files.map((file) => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve({ file, preview: reader.result });
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
});
|
||||
Promise.all(newItems).then((items) => {
|
||||
setPosterFiles((prev) => [...prev, ...items]);
|
||||
});
|
||||
e.target.value = "";
|
||||
};
|
||||
const removePoster = (index) => {
|
||||
setPosterFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// URL 추가/삭제
|
||||
const addUrl = () => {
|
||||
const url = urlInput.trim();
|
||||
if (!url) return;
|
||||
if (!postUrls.includes(url)) {
|
||||
setPostUrls([...postUrls, url]);
|
||||
}
|
||||
setUrlInput("");
|
||||
};
|
||||
const removeUrl = (index) => {
|
||||
setPostUrls(postUrls.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 제출
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim()) {
|
||||
setToast({ type: "error", message: "제목을 입력해주세요." });
|
||||
return;
|
||||
}
|
||||
if (!schoolName.trim()) {
|
||||
setToast({ type: "error", message: "학교명을 입력해주세요." });
|
||||
return;
|
||||
}
|
||||
if (!date) {
|
||||
setToast({ type: "error", message: "날짜를 선택해주세요." });
|
||||
return;
|
||||
}
|
||||
if (!venue) {
|
||||
setToast({ type: "error", message: "장소를 선택해주세요." });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
subtype,
|
||||
title: title.trim(),
|
||||
schoolName: schoolName.trim(),
|
||||
date,
|
||||
time: time || null,
|
||||
memberIds: selectedMemberIds,
|
||||
venue,
|
||||
postUrls,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("payload", JSON.stringify(payload));
|
||||
posterFiles.forEach((item) => {
|
||||
formData.append("posters", item.file);
|
||||
});
|
||||
|
||||
await createEvent(formData);
|
||||
|
||||
sessionStorage.setItem(
|
||||
"scheduleToast",
|
||||
JSON.stringify({ type: "success", message: "행사 일정이 추가되었습니다." })
|
||||
);
|
||||
navigate("/admin/schedule");
|
||||
} catch (err) {
|
||||
console.error("행사 저장 실패:", err);
|
||||
setToast({ type: "error", message: err.message || "저장에 실패했습니다." });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">기본 정보</h2>
|
||||
<div className="space-y-4">
|
||||
{/* 세부 타입 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
세부 타입
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SUBTYPES.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSubtype(opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm border transition-colors ${
|
||||
subtype === opt.value
|
||||
? "border-primary bg-primary text-white"
|
||||
: "border-gray-200 text-gray-600 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
제목 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: ○○대학교 대동제 초청 공연"
|
||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 학교명 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
|
||||
<GraduationCap size={14} />
|
||||
학교명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={schoolName}
|
||||
onChange={(e) => setSchoolName(e.target.value)}
|
||||
placeholder="예: 연세대학교"
|
||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 날짜/시간 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-1.5 block">날짜 *</label>
|
||||
<DatePicker value={date} onChange={setDate} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-1.5 block">시간 (선택)</label>
|
||||
<TimePicker value={time} onChange={setTime} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 장소 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
|
||||
<MapPin size={14} />
|
||||
장소 *
|
||||
</label>
|
||||
{venue ? (
|
||||
<div className="flex items-start gap-3 p-3 border border-gray-200 rounded-lg">
|
||||
<MapPin size={16} className="text-primary mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900">{venue.name}</p>
|
||||
{venue.address && (
|
||||
<p className="text-sm text-gray-500 truncate">{venue.address}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVenueDialogOpen(true)}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
변경
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVenue(null)}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVenueDialogOpen(true)}
|
||||
className="w-full px-3 py-2.5 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
장소 검색
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출연 멤버 */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-6">
|
||||
<Users size={18} />
|
||||
출연 멤버
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllMembers}
|
||||
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
|
||||
selectedMemberIds.length === members.length
|
||||
? "border-primary bg-primary text-white"
|
||||
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selectedMemberIds.length === members.length ? "전체 해제" : "전체 선택"}
|
||||
</button>
|
||||
{members.map((member) => {
|
||||
const isSelected = selectedMemberIds.includes(member.id);
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() => toggleMember(member.id)}
|
||||
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
|
||||
isSelected ? "border-primary" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
|
||||
{member.image_url ? (
|
||||
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-700">{member.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 포스터 */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-4">
|
||||
<ImageIcon size={18} />
|
||||
포스터 <span className="text-sm font-normal text-gray-400">(선택, 여러 장 가능)</span>
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{posterFiles.map((item, idx) => (
|
||||
<div key={idx} className="relative">
|
||||
<img src={item.preview} alt={`poster ${idx}`} className="h-32 w-32 object-cover rounded-lg border border-gray-200" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePoster(idx)}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs flex items-center justify-center hover:bg-red-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label className="flex items-center justify-center h-32 w-32 border-2 border-dashed border-gray-200 rounded-lg cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors">
|
||||
<div className="text-center">
|
||||
<ImageIcon size={20} className="mx-auto text-gray-400 mb-1" />
|
||||
<span className="text-xs text-gray-400">추가</span>
|
||||
</div>
|
||||
<input type="file" accept="image/*" multiple className="hidden" onChange={handlePosterChange} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-4">
|
||||
<Link2 size={18} />
|
||||
관련 URL <span className="text-sm font-normal text-gray-400">(선택, 여러 개 가능)</span>
|
||||
</h2>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="url"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addUrl();
|
||||
}
|
||||
}}
|
||||
placeholder="https://www.instagram.com/p/... 또는 공식 페이지"
|
||||
className="flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addUrl}
|
||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
{postUrls.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{postUrls.map((url, idx) => (
|
||||
<li key={idx} className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-lg">
|
||||
<a href={url} target="_blank" rel="noreferrer" className="flex-1 truncate text-sm text-gray-600 hover:underline">
|
||||
{url}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeUrl(idx)}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/admin/schedule")}
|
||||
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
저장
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
|
||||
{/* 장소 검색 다이얼로그 */}
|
||||
<LocationSearchDialog
|
||||
isOpen={venueDialogOpen}
|
||||
onClose={() => setVenueDialogOpen(false)}
|
||||
onSelect={(place) => setVenue(place)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventForm;
|
||||
|
|
@ -11,6 +11,7 @@ import YouTubeForm from "./YouTubeForm";
|
|||
import XForm from "./XForm";
|
||||
import ConcertForm from "./concert";
|
||||
import VarietyForm from "./VarietyForm";
|
||||
import EventForm from "./event";
|
||||
|
||||
// 애니메이션 variants
|
||||
const containerVariants = {
|
||||
|
|
@ -79,6 +80,9 @@ function ScheduleFormPage() {
|
|||
case '예능':
|
||||
return <VarietyForm />;
|
||||
|
||||
case '행사':
|
||||
return <EventForm />;
|
||||
|
||||
// 다른 카테고리는 기존 폼으로 리다이렉트
|
||||
default:
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react';
|
|||
import { getSchedule } from '@/api';
|
||||
|
||||
// 섹션 컴포넌트들
|
||||
import { YoutubeSection, XSection, VarietySection, DefaultSection, decodeHtmlEntities } from './sections';
|
||||
import { YoutubeSection, XSection, VarietySection, EventSection, DefaultSection, decodeHtmlEntities } from './sections';
|
||||
import Birthday from './Birthday';
|
||||
|
||||
/**
|
||||
|
|
@ -155,6 +155,8 @@ function PCScheduleDetail() {
|
|||
return <XSection schedule={schedule} />;
|
||||
case '예능':
|
||||
return <VarietySection schedule={schedule} />;
|
||||
case '행사':
|
||||
return <EventSection schedule={schedule} />;
|
||||
default:
|
||||
return <DefaultSection schedule={schedule} />;
|
||||
}
|
||||
|
|
@ -163,11 +165,12 @@ function PCScheduleDetail() {
|
|||
const isYoutube = categoryName === '유튜브';
|
||||
const isX = categoryName === 'X';
|
||||
const isVariety = categoryName === '예능';
|
||||
const hasCustomLayout = isYoutube || isX || isVariety;
|
||||
const isEvent = categoryName === '행사';
|
||||
const hasCustomLayout = isYoutube || isX || isVariety || isEvent;
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-gray-50">
|
||||
<div className={`${isYoutube ? 'max-w-5xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
|
||||
<div className={`${isYoutube || isEvent ? 'max-w-5xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
|
||||
{/* 브레드크럼 네비게이션 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
|
|
|
|||
221
frontend/src/pages/pc/public/schedule/sections/EventSection.jsx
Normal file
221
frontend/src/pages/pc/public/schedule/sections/EventSection.jsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { useState } from 'react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Navigation } from 'swiper/modules';
|
||||
import {
|
||||
Calendar, Clock, MapPin, Link2, GraduationCap, ExternalLink,
|
||||
ChevronLeft, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/navigation';
|
||||
import { Lightbox, KakaoMap } from '@/components/common';
|
||||
import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
|
||||
|
||||
/**
|
||||
* 행사 일정 섹션 컴포넌트 (학교 행사 등)
|
||||
*/
|
||||
function EventSection({ schedule }) {
|
||||
const members = schedule.members || [];
|
||||
const isFullGroup = members.length === 5;
|
||||
const posters = schedule.posters || [];
|
||||
const postUrls = schedule.postUrls || [];
|
||||
const venue = schedule.venue || null;
|
||||
const categoryColor = schedule.category?.color || '#facc15';
|
||||
const kakaoMapUrl = venue && venue.lat && venue.lng
|
||||
? `https://map.kakao.com/link/map/${encodeURIComponent(venue.name)},${venue.lat},${venue.lng}`
|
||||
: null;
|
||||
|
||||
const [lightbox, setLightbox] = useState({ open: false, index: 0 });
|
||||
const lightboxImages = posters.map((p) => p.originalUrl || p.mediumUrl);
|
||||
|
||||
const openLightbox = (index) => setLightbox({ open: true, index });
|
||||
|
||||
return (
|
||||
<div className="flex gap-5 items-start">
|
||||
{/* 왼쪽: 포스터 슬라이드 */}
|
||||
<div className="flex-shrink-0 w-[420px]">
|
||||
{posters.length > 0 ? (
|
||||
<div className="relative group bg-white rounded-2xl overflow-hidden shadow-sm border border-gray-100">
|
||||
<Swiper
|
||||
modules={[Navigation]}
|
||||
navigation={posters.length > 1 ? {
|
||||
prevEl: '.event-poster-prev',
|
||||
nextEl: '.event-poster-next',
|
||||
} : false}
|
||||
spaceBetween={0}
|
||||
slidesPerView={1}
|
||||
loop={posters.length > 1}
|
||||
className="w-full"
|
||||
>
|
||||
{posters.map((p, idx) => (
|
||||
<SwiperSlide key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLightbox(idx)}
|
||||
className="block w-full cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={p.mediumUrl || p.originalUrl}
|
||||
alt={`${schedule.title} 포스터 ${idx + 1}`}
|
||||
className="w-full h-auto object-cover"
|
||||
/>
|
||||
</button>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
{posters.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="event-poster-prev absolute left-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/80 hover:bg-white shadow-md flex items-center justify-center text-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label="이전 포스터"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="event-poster-next absolute right-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/80 hover:bg-white shadow-md flex items-center justify-center text-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label="다음 포스터"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
<div className="absolute bottom-3 right-3 px-2 py-0.5 rounded-full bg-black/50 text-white text-xs font-medium">
|
||||
{posters.length}장
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="w-full aspect-[3/4] bg-white rounded-2xl flex items-center justify-center border border-gray-100"
|
||||
style={{ backgroundColor: `${categoryColor}10` }}
|
||||
>
|
||||
<GraduationCap size={72} style={{ color: categoryColor }} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 정보 */}
|
||||
<div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
|
||||
{/* 학교 + 날짜 */}
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
{schedule.schoolName && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-base font-semibold rounded-md"
|
||||
style={{ backgroundColor: `${categoryColor}25`, color: '#92400e' }}
|
||||
>
|
||||
<GraduationCap size={15} />
|
||||
{schedule.schoolName}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5 text-base text-gray-500">
|
||||
<Calendar size={15} />
|
||||
{formatFullDate(schedule.date)}
|
||||
</span>
|
||||
{schedule.time && (
|
||||
<span className="flex items-center gap-1.5 text-base text-gray-500">
|
||||
<Clock size={15} />
|
||||
{formatTime(schedule.time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h1 className="text-2xl font-bold text-gray-900 leading-snug mb-6">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h1>
|
||||
|
||||
{/* 멤버 */}
|
||||
{members.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{isFullGroup ? (
|
||||
<span className="px-3.5 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
프로미스나인
|
||||
</span>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<span key={member.id} className="px-3.5 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
{member.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 장소 */}
|
||||
{venue && (
|
||||
<div className="pt-5 border-t border-gray-100 mb-5">
|
||||
<div className="flex items-start gap-2.5 mb-3">
|
||||
<MapPin size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-base text-gray-900">{venue.name}</p>
|
||||
{venue.address && (
|
||||
<p className="text-sm text-gray-500 mt-0.5">{venue.address}</p>
|
||||
)}
|
||||
{kakaoMapUrl && (
|
||||
<a
|
||||
href={kakaoMapUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
카카오맵에서 보기
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{venue.lat && venue.lng && (
|
||||
<KakaoMap
|
||||
lat={Number(venue.lat)}
|
||||
lng={Number(venue.lng)}
|
||||
name={venue.name}
|
||||
className="w-full h-52 rounded-xl overflow-hidden border border-gray-100"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL 목록 */}
|
||||
{postUrls.length > 0 && (
|
||||
<div className="pt-5 border-t border-gray-100">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-gray-700 mb-2">
|
||||
<Link2 size={15} />
|
||||
관련 링크
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{postUrls.map((url, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-300 select-none">·</span>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline truncate"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{posters.length > 0 && (
|
||||
<Lightbox
|
||||
images={lightboxImages}
|
||||
currentIndex={lightbox.index}
|
||||
isOpen={lightbox.open}
|
||||
onClose={() => setLightbox((prev) => ({ ...prev, open: false }))}
|
||||
onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))}
|
||||
showCounter={posters.length > 1}
|
||||
showDownload
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventSection;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export { default as YoutubeSection } from './YoutubeSection';
|
||||
export { default as XSection } from './XSection';
|
||||
export { default as VarietySection } from './VarietySection';
|
||||
export { default as EventSection } from './EventSection';
|
||||
export { default as DefaultSection } from './DefaultSection';
|
||||
export * from './utils';
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form';
|
|||
import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm';
|
||||
import AdminConcertEditForm from '@/pages/pc/admin/schedules/edit/ConcertEditForm';
|
||||
import AdminVarietyEditForm from '@/pages/pc/admin/schedules/edit/VarietyEditForm';
|
||||
import AdminEventEditForm from '@/pages/pc/admin/schedules/edit/EventEditForm';
|
||||
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
||||
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
||||
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
||||
|
|
@ -61,6 +62,7 @@ export default function AdminRoutes() {
|
|||
<Route path="/admin/schedule/:id/edit/youtube" element={<RequireAuth><AdminYouTubeEditForm /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/concert/:seriesId/edit" element={<RequireAuth><AdminConcertEditForm /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/:id/edit/variety" element={<RequireAuth><AdminVarietyEditForm /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/:id/edit/event" element={<RequireAuth><AdminEventEditForm /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/categories" element={<RequireAuth><AdminScheduleCategory /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue