feat(variety): 썸네일을 RustFS 이미지 업로드로 변경

- schedule/{id}/thumbnail/ 경로에 original/medium_800/thumb_400 webp 업로드
- images 테이블로 이미지 관리, schedule_variety.thumbnail_id로 참조
- 프론트엔드: URL 입력 → 파일 업로드(드래그&드롭) + 미리보기로 변경
- 수정 시 기존 썸네일 교체/삭제 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-04 19:07:08 +09:00
parent c88eb1fb60
commit a01d368728
7 changed files with 210 additions and 152 deletions

View file

@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS schedule_variety (
schedule_id INT NOT NULL, schedule_id INT NOT NULL,
broadcaster VARCHAR(100) NOT NULL COMMENT '방송사/플랫폼 (KBS, MBC, 유튜브, 티빙 등)', broadcaster VARCHAR(100) NOT NULL COMMENT '방송사/플랫폼 (KBS, MBC, 유튜브, 티빙 등)',
replay_url VARCHAR(500) DEFAULT NULL COMMENT '다시보기 링크', replay_url VARCHAR(500) DEFAULT NULL COMMENT '다시보기 링크',
thumbnail_url VARCHAR(500) DEFAULT NULL COMMENT '썸네일 이미지 URL', thumbnail_id INT DEFAULT NULL COMMENT '썸네일 이미지 ID (images 테이블 참조)',
PRIMARY KEY (schedule_id), PRIMARY KEY (schedule_id),
CONSTRAINT fk_variety_schedule FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE CASCADE CONSTRAINT fk_variety_schedule FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예능 일정 상세'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예능 일정 상세';

View file

@ -1,4 +1,5 @@
import { CATEGORY_IDS } from '../../config/index.js'; import { CATEGORY_IDS } from '../../config/index.js';
import { uploadVarietyThumbnail } from '../../services/image.js';
import { addOrUpdateSchedule, syncScheduleById } from '../../services/meilisearch/index.js'; import { addOrUpdateSchedule, syncScheduleById } from '../../services/meilisearch/index.js';
import { badRequest, notFound, serverError } from '../../utils/error.js'; import { badRequest, notFound, serverError } from '../../utils/error.js';
import { logActivity } from '../../utils/log.js'; import { logActivity } from '../../utils/log.js';
@ -13,61 +14,77 @@ export default async function varietyRoutes(fastify) {
/** /**
* POST /api/admin/variety/schedule * POST /api/admin/variety/schedule
* 예능 일정 저장 * 예능 일정 저장 (multipart/form-data)
*/ */
fastify.post('/schedule', { fastify.post('/schedule', {
schema: {
tags: ['admin/variety'],
summary: '예능 일정 저장',
security: [{ bearerAuth: [] }],
},
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
const { title, date, time, broadcaster, replayUrl, thumbnailUrl, memberIds } = request.body; const parts = request.parts();
if (!title?.trim()) { let title = '';
return badRequest(reply, '프로그램명은 필수입니다.'); let date = '';
} let time = null;
if (!date) { let broadcaster = '';
return badRequest(reply, '날짜는 필수입니다.'); let replayUrl = null;
} let memberIds = [];
if (!broadcaster?.trim()) { let thumbnailBuffer = null;
return badRequest(reply, '방송사/플랫폼은 필수입니다.');
for await (const part of parts) {
if (part.type === 'file' && part.fieldname === 'thumbnail') {
thumbnailBuffer = await part.toBuffer();
} else if (part.type === 'field') {
if (part.fieldname === 'title') title = part.value;
else if (part.fieldname === 'date') date = part.value;
else if (part.fieldname === 'time') time = part.value || null;
else if (part.fieldname === 'broadcaster') broadcaster = part.value;
else if (part.fieldname === 'replayUrl') replayUrl = part.value || null;
else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value);
}
} }
if (!title?.trim()) return badRequest(reply, '프로그램명은 필수입니다.');
if (!date) return badRequest(reply, '날짜는 필수입니다.');
if (!broadcaster?.trim()) return badRequest(reply, '방송사/플랫폼은 필수입니다.');
try { try {
// schedules 테이블 // schedules 테이블
const [scheduleResult] = await db.query( const [scheduleResult] = await db.query(
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
[VARIETY_CATEGORY_ID, title.trim(), date, time || null] [VARIETY_CATEGORY_ID, title.trim(), date, time]
); );
const scheduleId = scheduleResult.insertId; const scheduleId = scheduleResult.insertId;
// 썸네일 업로드
let thumbnailId = null;
if (thumbnailBuffer && thumbnailBuffer.length > 0) {
const { originalUrl, mediumUrl, thumbUrl } = await uploadVarietyThumbnail(scheduleId, thumbnailBuffer);
const [imgResult] = await db.query(
'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)',
[originalUrl, mediumUrl, thumbUrl]
);
thumbnailId = imgResult.insertId;
}
// schedule_variety 테이블 // schedule_variety 테이블
await db.query( await db.query(
'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_url) VALUES (?, ?, ?, ?)', 'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_id) VALUES (?, ?, ?, ?)',
[scheduleId, broadcaster.trim(), replayUrl?.trim() || null, thumbnailUrl?.trim() || null] [scheduleId, broadcaster.trim(), replayUrl?.trim() || null, thumbnailId]
); );
// schedule_members 테이블 // schedule_members 테이블
if (memberIds && memberIds.length > 0) { if (memberIds.length > 0) {
const values = memberIds.map(memberId => [scheduleId, memberId]); const values = memberIds.map(memberId => [scheduleId, memberId]);
await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
} }
// Meilisearch 동기화 // Meilisearch 동기화
const [categoryRows] = await db.query( const [categoryRows] = await db.query('SELECT name, color FROM schedule_categories WHERE id = ?', [VARIETY_CATEGORY_ID]);
'SELECT name, color FROM schedule_categories WHERE id = ?',
[VARIETY_CATEGORY_ID]
);
const category = categoryRows[0] || {}; const category = categoryRows[0] || {};
let memberNames = ''; let memberNames = '';
if (memberIds && memberIds.length > 0) { if (memberIds.length > 0) {
const [members] = await db.query('SELECT name FROM members WHERE id IN (?) ORDER BY id', [memberIds]); const [members] = await db.query('SELECT name FROM members WHERE id IN (?) ORDER BY id', [memberIds]);
memberNames = members.map(m => m.name).join(','); memberNames = members.map(m => m.name).join(',');
} }
await addOrUpdateSchedule(meilisearch, { await addOrUpdateSchedule(meilisearch, {
id: scheduleId, id: scheduleId,
title: title.trim(), title: title.trim(),
@ -79,15 +96,7 @@ export default async function varietyRoutes(fastify) {
member_names: memberNames, member_names: memberNames,
}); });
logActivity(db, { logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'variety_schedule', targetId: scheduleId, summary: `예능 일정 생성: ${title.trim()}` });
actor: 'admin',
action: 'create',
category: 'schedule',
targetType: 'variety_schedule',
targetId: scheduleId,
summary: `예능 일정 생성: ${title.trim()}`,
});
return { success: true, scheduleId }; return { success: true, scheduleId };
} catch (err) { } catch (err) {
fastify.log.error(`예능 일정 저장 오류: ${err.message}`); fastify.log.error(`예능 일정 저장 오류: ${err.message}`);
@ -97,69 +106,82 @@ export default async function varietyRoutes(fastify) {
/** /**
* PUT /api/admin/variety/schedule/:id * PUT /api/admin/variety/schedule/:id
* 예능 일정 수정 * 예능 일정 수정 (multipart/form-data)
*/ */
fastify.put('/schedule/:id', { fastify.put('/schedule/:id', {
schema: {
tags: ['admin/variety'],
summary: '예능 일정 수정',
security: [{ bearerAuth: [] }],
},
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
const { id } = request.params; const { id } = request.params;
const { title, date, time, broadcaster, replayUrl, thumbnailUrl, memberIds } = request.body; const parts = request.parts();
if (!title?.trim()) { let title = '';
return badRequest(reply, '프로그램명은 필수입니다.'); let date = '';
let time = null;
let broadcaster = '';
let replayUrl = null;
let memberIds = [];
let thumbnailBuffer = null;
let removeThumbnail = false;
for await (const part of parts) {
if (part.type === 'file' && part.fieldname === 'thumbnail') {
thumbnailBuffer = await part.toBuffer();
} else if (part.type === 'field') {
if (part.fieldname === 'title') title = part.value;
else if (part.fieldname === 'date') date = part.value;
else if (part.fieldname === 'time') time = part.value || null;
else if (part.fieldname === 'broadcaster') broadcaster = part.value;
else if (part.fieldname === 'replayUrl') replayUrl = part.value || null;
else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value);
else if (part.fieldname === 'removeThumbnail') removeThumbnail = part.value === 'true';
}
} }
if (!title?.trim()) return badRequest(reply, '프로그램명은 필수입니다.');
try { try {
// 존재 확인
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]); const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
if (existing.length === 0) { if (existing.length === 0) return notFound(reply, '일정을 찾을 수 없습니다.');
return notFound(reply, '일정을 찾을 수 없습니다.');
}
// schedules 업데이트 // schedules 업데이트
await db.query( await db.query('UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?', [title.trim(), date, time, id]);
'UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?',
[title.trim(), date, time || null, id]
);
// schedule_variety 업데이트 (upsert) // 기존 variety 데이터 조회
const [varietyExisting] = await db.query('SELECT schedule_id FROM schedule_variety WHERE schedule_id = ?', [id]); const [varietyRows] = await db.query('SELECT thumbnail_id FROM schedule_variety WHERE schedule_id = ?', [id]);
if (varietyExisting.length > 0) { let thumbnailId = varietyRows[0]?.thumbnail_id || null;
await db.query(
'UPDATE schedule_variety SET broadcaster = ?, replay_url = ?, thumbnail_url = ? WHERE schedule_id = ?', // 썸네일 업데이트
[broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailUrl?.trim() || null, id] if (thumbnailBuffer && thumbnailBuffer.length > 0) {
); const { originalUrl, mediumUrl, thumbUrl } = await uploadVarietyThumbnail(id, thumbnailBuffer);
if (thumbnailId) {
await db.query('UPDATE images SET original_url = ?, medium_url = ?, thumb_url = ? WHERE id = ?', [originalUrl, mediumUrl, thumbUrl, thumbnailId]);
} else {
const [imgResult] = await db.query('INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)', [originalUrl, mediumUrl, thumbUrl]);
thumbnailId = imgResult.insertId;
}
} else if (removeThumbnail && thumbnailId) {
await db.query('DELETE FROM images WHERE id = ?', [thumbnailId]);
thumbnailId = null;
}
// schedule_variety upsert
if (varietyRows.length > 0) {
await db.query('UPDATE schedule_variety SET broadcaster = ?, replay_url = ?, thumbnail_id = ? WHERE schedule_id = ?',
[broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailId, id]);
} else { } else {
await db.query( await db.query('INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_id) VALUES (?, ?, ?, ?)',
'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_url) VALUES (?, ?, ?, ?)', [id, broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailId]);
[id, broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailUrl?.trim() || null]
);
} }
// 멤버 업데이트 // 멤버 업데이트
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]); await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
if (memberIds && memberIds.length > 0) { if (memberIds.length > 0) {
const values = memberIds.map(memberId => [id, memberId]); const values = memberIds.map(memberId => [id, memberId]);
await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
} }
// Meilisearch 동기화
await syncScheduleById(meilisearch, db, parseInt(id)); await syncScheduleById(meilisearch, db, parseInt(id));
logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'variety_schedule', targetId: parseInt(id), summary: `예능 일정 수정: ${title.trim()}` });
logActivity(db, {
actor: 'admin',
action: 'update',
category: 'schedule',
targetType: 'variety_schedule',
targetId: parseInt(id),
summary: `예능 일정 수정: ${title.trim()}`,
});
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
fastify.log.error(`예능 일정 수정 오류: ${err.message}`); fastify.log.error(`예능 일정 수정 오류: ${err.message}`);
@ -172,11 +194,6 @@ export default async function varietyRoutes(fastify) {
* 예능 일정 상세 조회 (수정 폼용) * 예능 일정 상세 조회 (수정 폼용)
*/ */
fastify.get('/schedule/:id', { fastify.get('/schedule/:id', {
schema: {
tags: ['admin/variety'],
summary: '예능 일정 상세 조회',
security: [{ bearerAuth: [] }],
},
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
}, async (request, reply) => { }, async (request, reply) => {
const { id } = request.params; const { id } = request.params;
@ -184,32 +201,27 @@ export default async function varietyRoutes(fastify) {
try { try {
const [rows] = await db.query(` const [rows] = await db.query(`
SELECT s.id, s.title, s.date, s.time, SELECT s.id, s.title, s.date, s.time,
sv.broadcaster, sv.replay_url, sv.thumbnail_url sv.broadcaster, sv.replay_url, sv.thumbnail_id,
i.original_url as thumb_original, i.medium_url as thumb_medium, i.thumb_url as thumb_thumb
FROM schedules s FROM schedules s
LEFT JOIN schedule_variety sv ON s.id = sv.schedule_id LEFT JOIN schedule_variety sv ON s.id = sv.schedule_id
LEFT JOIN images i ON sv.thumbnail_id = i.id
WHERE s.id = ? WHERE s.id = ?
`, [id]); `, [id]);
if (rows.length === 0) { if (rows.length === 0) return notFound(reply, '일정을 찾을 수 없습니다.');
return notFound(reply, '일정을 찾을 수 없습니다.');
}
const schedule = rows[0]; const s = rows[0];
const [memberRows] = await db.query('SELECT member_id FROM schedule_members WHERE schedule_id = ?', [id]);
// 멤버 조회
const [memberRows] = await db.query(
'SELECT member_id FROM schedule_members WHERE schedule_id = ?',
[id]
);
return { return {
id: schedule.id, id: s.id,
title: schedule.title, title: s.title,
date: schedule.date instanceof Date ? schedule.date.toISOString().split('T')[0] : schedule.date?.split('T')[0] || '', date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date?.split('T')[0] || '',
time: schedule.time ? schedule.time.substring(0, 5) : '', time: s.time ? s.time.substring(0, 5) : '',
broadcaster: schedule.broadcaster || '', broadcaster: s.broadcaster || '',
replayUrl: schedule.replay_url || '', replayUrl: s.replay_url || '',
thumbnailUrl: schedule.thumbnail_url || '', thumbnailUrl: s.thumb_medium || s.thumb_original || '',
memberIds: memberRows.map(r => r.member_id), memberIds: memberRows.map(r => r.member_id),
}; };
} catch (err) { } catch (err) {

View file

@ -256,3 +256,23 @@ export async function uploadConcertMerchandise(seriesId, filename, buffer) {
return { originalUrl, mediumUrl, thumbUrl }; return { originalUrl, mediumUrl, thumbUrl };
} }
/**
* 예능 일정 썸네일 업로드
* @param {number} scheduleId - 일정 ID
* @param {Buffer} buffer - 이미지 버퍼
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>}
*/
export async function uploadVarietyThumbnail(scheduleId, buffer) {
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
const basePath = `schedule/${scheduleId}/thumbnail`;
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
uploadToS3(`${basePath}/original/thumbnail.webp`, originalBuffer),
uploadToS3(`${basePath}/medium_800/thumbnail.webp`, mediumBuffer),
uploadToS3(`${basePath}/thumb_400/thumbnail.webp`, thumbBuffer),
]);
return { originalUrl, mediumUrl, thumbUrl };
}

View file

@ -203,12 +203,13 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
sx.image_urls as x_image_urls, sx.image_urls as x_image_urls,
sv.broadcaster as variety_broadcaster, sv.broadcaster as variety_broadcaster,
sv.replay_url as variety_replay_url, sv.replay_url as variety_replay_url,
sv.thumbnail_url as variety_thumbnail_url svi.medium_url as variety_thumbnail_url
FROM schedules s FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id 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_youtube sy ON s.id = sy.schedule_id
LEFT JOIN schedule_x sx ON s.id = sx.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 schedule_variety sv ON s.id = sv.schedule_id
LEFT JOIN images svi ON sv.thumbnail_id = svi.id
WHERE s.id = ? WHERE s.id = ?
`, [id]); `, [id]);

View file

@ -1,16 +1,13 @@
/** /**
* 예능 관리자 API * 예능 관리자 API
*/ */
import { fetchAuthApi } from '@/api/client'; import { fetchAuthApi, fetchFormData } from '@/api/client';
/** /**
* 예능 일정 생성 * 예능 일정 생성
*/ */
export async function createVarietySchedule(data) { export async function createVarietySchedule(formData) {
return fetchAuthApi('/admin/variety/schedule', { return fetchFormData('/admin/variety/schedule', formData, 'POST');
method: 'POST',
body: JSON.stringify(data),
});
} }
/** /**
@ -23,9 +20,6 @@ export async function getVarietySchedule(id) {
/** /**
* 예능 일정 수정 * 예능 일정 수정
*/ */
export async function updateVarietySchedule(id, data) { export async function updateVarietySchedule(id, formData) {
return fetchAuthApi(`/admin/variety/schedule/${id}`, { return fetchFormData(`/admin/variety/schedule/${id}`, formData, 'PUT');
method: 'PUT',
body: JSON.stringify(data),
});
} }

View file

@ -46,7 +46,9 @@ function VarietyEditForm() {
const [date, setDate] = useState(""); const [date, setDate] = useState("");
const [time, setTime] = useState(""); const [time, setTime] = useState("");
const [replayUrl, setReplayUrl] = useState(""); const [replayUrl, setReplayUrl] = useState("");
const [thumbnailUrl, setThumbnailUrl] = useState(""); const [thumbnailFile, setThumbnailFile] = useState(null);
const [thumbnailPreview, setThumbnailPreview] = useState(null);
const [removeThumbnail, setRemoveThumbnail] = useState(false);
const [selectedMemberIds, setSelectedMemberIds] = useState([]); const [selectedMemberIds, setSelectedMemberIds] = useState([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
@ -58,7 +60,7 @@ function VarietyEditForm() {
setDate(scheduleData.date || ""); setDate(scheduleData.date || "");
setTime(scheduleData.time || ""); setTime(scheduleData.time || "");
setReplayUrl(scheduleData.replayUrl || ""); setReplayUrl(scheduleData.replayUrl || "");
setThumbnailUrl(scheduleData.thumbnailUrl || ""); if (scheduleData.thumbnailUrl) setThumbnailPreview(scheduleData.thumbnailUrl);
setSelectedMemberIds(scheduleData.memberIds || []); setSelectedMemberIds(scheduleData.memberIds || []);
setInitialized(true); setInitialized(true);
} }
@ -85,15 +87,17 @@ function VarietyEditForm() {
setSaving(true); setSaving(true);
try { try {
await updateVarietySchedule(id, { const formData = new FormData();
title: title.trim(), formData.append("title", title.trim());
broadcaster: broadcaster.trim(), formData.append("broadcaster", broadcaster.trim());
date, formData.append("date", date);
time: time || null, if (time) formData.append("time", time);
replayUrl: replayUrl.trim() || null, if (replayUrl.trim()) formData.append("replayUrl", replayUrl.trim());
thumbnailUrl: thumbnailUrl.trim() || null, formData.append("memberIds", JSON.stringify(selectedMemberIds));
memberIds: selectedMemberIds, if (thumbnailFile) formData.append("thumbnail", thumbnailFile);
}); if (removeThumbnail) formData.append("removeThumbnail", "true");
await updateVarietySchedule(id, formData);
sessionStorage.setItem("scheduleToast", JSON.stringify({ type: "success", message: "예능 일정이 수정되었습니다." })); sessionStorage.setItem("scheduleToast", JSON.stringify({ type: "success", message: "예능 일정이 수정되었습니다." }));
navigate("/admin/schedule"); navigate("/admin/schedule");
} catch (err) { } catch (err) {
@ -176,9 +180,18 @@ function VarietyEditForm() {
<input type="url" value={replayUrl} onChange={(e) => setReplayUrl(e.target.value)} placeholder="https://..." 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" /> <input type="url" value={replayUrl} onChange={(e) => setReplayUrl(e.target.value)} placeholder="https://..." 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>
<div> <div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Image size={14} />썸네일 이미지 URL (선택)</label> <label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Image size={14} />썸네일 이미지 (선택)</label>
<input type="url" value={thumbnailUrl} onChange={(e) => setThumbnailUrl(e.target.value)} placeholder="https://..." 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" /> {thumbnailPreview ? (
{thumbnailUrl && <img src={thumbnailUrl} alt="미리보기" className="mt-2 h-32 rounded-lg object-cover" onError={(e) => { e.target.style.display = 'none'; }} />} <div className="relative inline-block">
<img src={thumbnailPreview} alt="미리보기" className="h-32 rounded-lg object-cover" />
<button type="button" onClick={() => { setThumbnailFile(null); setThumbnailPreview(null); setRemoveThumbnail(true); }} 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 w-full h-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"><Image size={24} className="mx-auto text-gray-400 mb-1" /><span className="text-sm text-gray-400">클릭하여 이미지 선택</span></div>
<input type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files[0]; if (f) { setThumbnailFile(f); setRemoveThumbnail(false); const r = new FileReader(); r.onloadend = () => setThumbnailPreview(r.result); r.readAsDataURL(f); } }} />
</label>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -35,7 +35,8 @@ function VarietyForm() {
const [date, setDate] = useState(""); const [date, setDate] = useState("");
const [time, setTime] = useState(""); const [time, setTime] = useState("");
const [replayUrl, setReplayUrl] = useState(""); const [replayUrl, setReplayUrl] = useState("");
const [thumbnailUrl, setThumbnailUrl] = useState(""); const [thumbnailFile, setThumbnailFile] = useState(null);
const [thumbnailPreview, setThumbnailPreview] = useState(null);
const [selectedMemberIds, setSelectedMemberIds] = useState([]); const [selectedMemberIds, setSelectedMemberIds] = useState([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -82,15 +83,16 @@ function VarietyForm() {
setSaving(true); setSaving(true);
try { try {
await createVarietySchedule({ const formData = new FormData();
title: title.trim(), formData.append("title", title.trim());
broadcaster: broadcaster.trim(), formData.append("broadcaster", broadcaster.trim());
date, formData.append("date", date);
time: time || null, if (time) formData.append("time", time);
replayUrl: replayUrl.trim() || null, if (replayUrl.trim()) formData.append("replayUrl", replayUrl.trim());
thumbnailUrl: thumbnailUrl.trim() || null, formData.append("memberIds", JSON.stringify(selectedMemberIds));
memberIds: selectedMemberIds, if (thumbnailFile) formData.append("thumbnail", thumbnailFile);
});
await createVarietySchedule(formData);
sessionStorage.setItem( sessionStorage.setItem(
"scheduleToast", "scheduleToast",
@ -244,28 +246,44 @@ function VarietyForm() {
/> />
</div> </div>
{/* 썸네일 URL */} {/* 썸네일 이미지 */}
<div> <div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"> <label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
<Image size={14} /> <Image size={14} />
썸네일 이미지 URL (선택) 썸네일 이미지 (선택)
</label> </label>
<input {thumbnailPreview ? (
type="url" <div className="relative inline-block">
value={thumbnailUrl} <img src={thumbnailPreview} alt="썸네일 미리보기" className="h-32 rounded-lg object-cover" />
onChange={(e) => setThumbnailUrl(e.target.value)} <button
placeholder="https://..." type="button"
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" onClick={() => { setThumbnailFile(null); setThumbnailPreview(null); }}
/> 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"
{thumbnailUrl && ( >
<div className="mt-2">
<img </button>
src={thumbnailUrl}
alt="썸네일 미리보기"
className="h-32 rounded-lg object-cover"
onError={(e) => { e.target.style.display = 'none'; }}
/>
</div> </div>
) : (
<label className="flex items-center justify-center w-full h-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">
<Image size={24} className="mx-auto text-gray-400 mb-1" />
<span className="text-sm text-gray-400">클릭하여 이미지 선택</span>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files[0];
if (file) {
setThumbnailFile(file);
const reader = new FileReader();
reader.onloadend = () => setThumbnailPreview(reader.result);
reader.readAsDataURL(file);
}
}}
/>
</label>
)} )}
</div> </div>
</div> </div>