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,
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='예능 일정 상세';

View file

@ -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) {

View file

@ -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 };
}

View file

@ -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]);

View file

@ -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');
}

View file

@ -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>

View file

@ -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>