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:
parent
c88eb1fb60
commit
a01d368728
7 changed files with 210 additions and 152 deletions
|
|
@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS schedule_variety (
|
|||
schedule_id INT NOT NULL,
|
||||
broadcaster VARCHAR(100) NOT NULL COMMENT '방송사/플랫폼 (KBS, MBC, 유튜브, 티빙 등)',
|
||||
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),
|
||||
CONSTRAINT fk_variety_schedule FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예능 일정 상세';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { CATEGORY_IDS } from '../../config/index.js';
|
||||
import { uploadVarietyThumbnail } from '../../services/image.js';
|
||||
import { addOrUpdateSchedule, syncScheduleById } from '../../services/meilisearch/index.js';
|
||||
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
||||
import { logActivity } from '../../utils/log.js';
|
||||
|
|
@ -13,61 +14,77 @@ export default async function varietyRoutes(fastify) {
|
|||
|
||||
/**
|
||||
* POST /api/admin/variety/schedule
|
||||
* 예능 일정 저장
|
||||
* 예능 일정 저장 (multipart/form-data)
|
||||
*/
|
||||
fastify.post('/schedule', {
|
||||
schema: {
|
||||
tags: ['admin/variety'],
|
||||
summary: '예능 일정 저장',
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { title, date, time, broadcaster, replayUrl, thumbnailUrl, memberIds } = request.body;
|
||||
const parts = request.parts();
|
||||
|
||||
if (!title?.trim()) {
|
||||
return badRequest(reply, '프로그램명은 필수입니다.');
|
||||
}
|
||||
if (!date) {
|
||||
return badRequest(reply, '날짜는 필수입니다.');
|
||||
}
|
||||
if (!broadcaster?.trim()) {
|
||||
return badRequest(reply, '방송사/플랫폼은 필수입니다.');
|
||||
let title = '';
|
||||
let date = '';
|
||||
let time = null;
|
||||
let broadcaster = '';
|
||||
let replayUrl = null;
|
||||
let memberIds = [];
|
||||
let thumbnailBuffer = null;
|
||||
|
||||
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 {
|
||||
// schedules 테이블
|
||||
const [scheduleResult] = await db.query(
|
||||
'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;
|
||||
|
||||
// 썸네일 업로드
|
||||
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 테이블
|
||||
await db.query(
|
||||
'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_url) VALUES (?, ?, ?, ?)',
|
||||
[scheduleId, broadcaster.trim(), replayUrl?.trim() || null, thumbnailUrl?.trim() || null]
|
||||
'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_id) VALUES (?, ?, ?, ?)',
|
||||
[scheduleId, broadcaster.trim(), replayUrl?.trim() || null, thumbnailId]
|
||||
);
|
||||
|
||||
// schedule_members 테이블
|
||||
if (memberIds && memberIds.length > 0) {
|
||||
if (memberIds.length > 0) {
|
||||
const values = memberIds.map(memberId => [scheduleId, memberId]);
|
||||
await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
|
||||
}
|
||||
|
||||
// Meilisearch 동기화
|
||||
const [categoryRows] = await db.query(
|
||||
'SELECT name, color FROM schedule_categories WHERE id = ?',
|
||||
[VARIETY_CATEGORY_ID]
|
||||
);
|
||||
const [categoryRows] = await db.query('SELECT name, color FROM schedule_categories WHERE id = ?', [VARIETY_CATEGORY_ID]);
|
||||
const category = categoryRows[0] || {};
|
||||
|
||||
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]);
|
||||
memberNames = members.map(m => m.name).join(',');
|
||||
}
|
||||
|
||||
await addOrUpdateSchedule(meilisearch, {
|
||||
id: scheduleId,
|
||||
title: title.trim(),
|
||||
|
|
@ -79,15 +96,7 @@ export default async function varietyRoutes(fastify) {
|
|||
member_names: memberNames,
|
||||
});
|
||||
|
||||
logActivity(db, {
|
||||
actor: 'admin',
|
||||
action: 'create',
|
||||
category: 'schedule',
|
||||
targetType: 'variety_schedule',
|
||||
targetId: scheduleId,
|
||||
summary: `예능 일정 생성: ${title.trim()}`,
|
||||
});
|
||||
|
||||
logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'variety_schedule', targetId: scheduleId, summary: `예능 일정 생성: ${title.trim()}` });
|
||||
return { success: true, scheduleId };
|
||||
} catch (err) {
|
||||
fastify.log.error(`예능 일정 저장 오류: ${err.message}`);
|
||||
|
|
@ -97,69 +106,82 @@ export default async function varietyRoutes(fastify) {
|
|||
|
||||
/**
|
||||
* PUT /api/admin/variety/schedule/:id
|
||||
* 예능 일정 수정
|
||||
* 예능 일정 수정 (multipart/form-data)
|
||||
*/
|
||||
fastify.put('/schedule/:id', {
|
||||
schema: {
|
||||
tags: ['admin/variety'],
|
||||
summary: '예능 일정 수정',
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { title, date, time, broadcaster, replayUrl, thumbnailUrl, memberIds } = request.body;
|
||||
const parts = request.parts();
|
||||
|
||||
if (!title?.trim()) {
|
||||
return badRequest(reply, '프로그램명은 필수입니다.');
|
||||
let title = '';
|
||||
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 {
|
||||
// 존재 확인
|
||||
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
|
||||
if (existing.length === 0) {
|
||||
return notFound(reply, '일정을 찾을 수 없습니다.');
|
||||
}
|
||||
if (existing.length === 0) return notFound(reply, '일정을 찾을 수 없습니다.');
|
||||
|
||||
// schedules 업데이트
|
||||
await db.query(
|
||||
'UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?',
|
||||
[title.trim(), date, time || null, id]
|
||||
);
|
||||
await db.query('UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?', [title.trim(), date, time, id]);
|
||||
|
||||
// schedule_variety 업데이트 (upsert)
|
||||
const [varietyExisting] = await db.query('SELECT schedule_id FROM schedule_variety WHERE schedule_id = ?', [id]);
|
||||
if (varietyExisting.length > 0) {
|
||||
await db.query(
|
||||
'UPDATE schedule_variety SET broadcaster = ?, replay_url = ?, thumbnail_url = ? WHERE schedule_id = ?',
|
||||
[broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailUrl?.trim() || null, id]
|
||||
);
|
||||
// 기존 variety 데이터 조회
|
||||
const [varietyRows] = await db.query('SELECT thumbnail_id FROM schedule_variety WHERE schedule_id = ?', [id]);
|
||||
let thumbnailId = varietyRows[0]?.thumbnail_id || null;
|
||||
|
||||
// 썸네일 업데이트
|
||||
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 {
|
||||
await db.query(
|
||||
'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_url) VALUES (?, ?, ?, ?)',
|
||||
[id, broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailUrl?.trim() || null]
|
||||
);
|
||||
await db.query('INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_id) VALUES (?, ?, ?, ?)',
|
||||
[id, broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailId]);
|
||||
}
|
||||
|
||||
// 멤버 업데이트
|
||||
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]);
|
||||
await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
|
||||
}
|
||||
|
||||
// Meilisearch 동기화
|
||||
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 };
|
||||
} catch (err) {
|
||||
fastify.log.error(`예능 일정 수정 오류: ${err.message}`);
|
||||
|
|
@ -172,11 +194,6 @@ export default async function varietyRoutes(fastify) {
|
|||
* 예능 일정 상세 조회 (수정 폼용)
|
||||
*/
|
||||
fastify.get('/schedule/:id', {
|
||||
schema: {
|
||||
tags: ['admin/variety'],
|
||||
summary: '예능 일정 상세 조회',
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
|
@ -184,32 +201,27 @@ export default async function varietyRoutes(fastify) {
|
|||
try {
|
||||
const [rows] = await db.query(`
|
||||
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
|
||||
LEFT JOIN schedule_variety sv ON s.id = sv.schedule_id
|
||||
LEFT JOIN images i ON sv.thumbnail_id = i.id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return notFound(reply, '일정을 찾을 수 없습니다.');
|
||||
}
|
||||
if (rows.length === 0) return notFound(reply, '일정을 찾을 수 없습니다.');
|
||||
|
||||
const schedule = rows[0];
|
||||
|
||||
// 멤버 조회
|
||||
const [memberRows] = await db.query(
|
||||
'SELECT member_id FROM schedule_members WHERE schedule_id = ?',
|
||||
[id]
|
||||
);
|
||||
const s = rows[0];
|
||||
const [memberRows] = await db.query('SELECT member_id FROM schedule_members WHERE schedule_id = ?', [id]);
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
title: schedule.title,
|
||||
date: schedule.date instanceof Date ? schedule.date.toISOString().split('T')[0] : schedule.date?.split('T')[0] || '',
|
||||
time: schedule.time ? schedule.time.substring(0, 5) : '',
|
||||
broadcaster: schedule.broadcaster || '',
|
||||
replayUrl: schedule.replay_url || '',
|
||||
thumbnailUrl: schedule.thumbnail_url || '',
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date?.split('T')[0] || '',
|
||||
time: s.time ? s.time.substring(0, 5) : '',
|
||||
broadcaster: s.broadcaster || '',
|
||||
replayUrl: s.replay_url || '',
|
||||
thumbnailUrl: s.thumb_medium || s.thumb_original || '',
|
||||
memberIds: memberRows.map(r => r.member_id),
|
||||
};
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -256,3 +256,23 @@ export async function uploadConcertMerchandise(seriesId, filename, buffer) {
|
|||
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,12 +203,13 @@ 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,
|
||||
sv.thumbnail_url as variety_thumbnail_url
|
||||
svi.medium_url as variety_thumbnail_url
|
||||
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
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
/**
|
||||
* 예능 관리자 API
|
||||
*/
|
||||
import { fetchAuthApi } from '@/api/client';
|
||||
import { fetchAuthApi, fetchFormData } from '@/api/client';
|
||||
|
||||
/**
|
||||
* 예능 일정 생성
|
||||
*/
|
||||
export async function createVarietySchedule(data) {
|
||||
return fetchAuthApi('/admin/variety/schedule', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export async function createVarietySchedule(formData) {
|
||||
return fetchFormData('/admin/variety/schedule', formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -23,9 +20,6 @@ export async function getVarietySchedule(id) {
|
|||
/**
|
||||
* 예능 일정 수정
|
||||
*/
|
||||
export async function updateVarietySchedule(id, data) {
|
||||
return fetchAuthApi(`/admin/variety/schedule/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export async function updateVarietySchedule(id, formData) {
|
||||
return fetchFormData(`/admin/variety/schedule/${id}`, formData, 'PUT');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ function VarietyEditForm() {
|
|||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = 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 [saving, setSaving] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
|
@ -58,7 +60,7 @@ function VarietyEditForm() {
|
|||
setDate(scheduleData.date || "");
|
||||
setTime(scheduleData.time || "");
|
||||
setReplayUrl(scheduleData.replayUrl || "");
|
||||
setThumbnailUrl(scheduleData.thumbnailUrl || "");
|
||||
if (scheduleData.thumbnailUrl) setThumbnailPreview(scheduleData.thumbnailUrl);
|
||||
setSelectedMemberIds(scheduleData.memberIds || []);
|
||||
setInitialized(true);
|
||||
}
|
||||
|
|
@ -85,15 +87,17 @@ function VarietyEditForm() {
|
|||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateVarietySchedule(id, {
|
||||
title: title.trim(),
|
||||
broadcaster: broadcaster.trim(),
|
||||
date,
|
||||
time: time || null,
|
||||
replayUrl: replayUrl.trim() || null,
|
||||
thumbnailUrl: thumbnailUrl.trim() || null,
|
||||
memberIds: selectedMemberIds,
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("title", title.trim());
|
||||
formData.append("broadcaster", broadcaster.trim());
|
||||
formData.append("date", date);
|
||||
if (time) formData.append("time", time);
|
||||
if (replayUrl.trim()) formData.append("replayUrl", replayUrl.trim());
|
||||
formData.append("memberIds", JSON.stringify(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: "예능 일정이 수정되었습니다." }));
|
||||
navigate("/admin/schedule");
|
||||
} 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Image size={14} />썸네일 이미지 URL (선택)</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" />
|
||||
{thumbnailUrl && <img src={thumbnailUrl} alt="미리보기" className="mt-2 h-32 rounded-lg object-cover" onError={(e) => { e.target.style.display = 'none'; }} />}
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Image size={14} />썸네일 이미지 (선택)</label>
|
||||
{thumbnailPreview ? (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ function VarietyForm() {
|
|||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("");
|
||||
const [replayUrl, setReplayUrl] = useState("");
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState("");
|
||||
const [thumbnailFile, setThumbnailFile] = useState(null);
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState(null);
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
|
|
@ -82,15 +83,16 @@ function VarietyForm() {
|
|||
setSaving(true);
|
||||
|
||||
try {
|
||||
await createVarietySchedule({
|
||||
title: title.trim(),
|
||||
broadcaster: broadcaster.trim(),
|
||||
date,
|
||||
time: time || null,
|
||||
replayUrl: replayUrl.trim() || null,
|
||||
thumbnailUrl: thumbnailUrl.trim() || null,
|
||||
memberIds: selectedMemberIds,
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("title", title.trim());
|
||||
formData.append("broadcaster", broadcaster.trim());
|
||||
formData.append("date", date);
|
||||
if (time) formData.append("time", time);
|
||||
if (replayUrl.trim()) formData.append("replayUrl", replayUrl.trim());
|
||||
formData.append("memberIds", JSON.stringify(selectedMemberIds));
|
||||
if (thumbnailFile) formData.append("thumbnail", thumbnailFile);
|
||||
|
||||
await createVarietySchedule(formData);
|
||||
|
||||
sessionStorage.setItem(
|
||||
"scheduleToast",
|
||||
|
|
@ -244,28 +246,44 @@ function VarietyForm() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 썸네일 URL */}
|
||||
{/* 썸네일 이미지 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
|
||||
<Image size={14} />
|
||||
썸네일 이미지 URL (선택)
|
||||
썸네일 이미지 (선택)
|
||||
</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"
|
||||
/>
|
||||
{thumbnailUrl && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt="썸네일 미리보기"
|
||||
className="h-32 rounded-lg object-cover"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
{thumbnailPreview ? (
|
||||
<div className="relative inline-block">
|
||||
<img src={thumbnailPreview} alt="썸네일 미리보기" className="h-32 rounded-lg object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
✕
|
||||
</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 file = e.target.files[0];
|
||||
if (file) {
|
||||
setThumbnailFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setThumbnailPreview(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue