| @@ -6,6 +6,8 @@ import { Card, Portal, MediaItem } from '@/types'; | |||
| const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url); | |||
| const isPdfFile = (file: File) => | |||
| file.type === 'application/pdf' || /\.pdf$/i.test(file.name); | |||
| const isVideoFile = (file: File) => | |||
| file.type.startsWith('video/') || /\.(mp4|webm|mov|m4v|ogv)$/i.test(file.name); | |||
| async function uploadBlobAsImage(blob: Blob, name: string): Promise<string | null> { | |||
| const formData = new FormData(); | |||
| @@ -15,6 +17,42 @@ async function uploadBlobAsImage(blob: Blob, name: string): Promise<string | nul | |||
| return data.url || null; | |||
| } | |||
| async function extractVideoFrame(file: File): Promise<Blob | null> { | |||
| const url = URL.createObjectURL(file); | |||
| try { | |||
| const video = document.createElement('video'); | |||
| video.muted = true; | |||
| video.playsInline = true; | |||
| video.preload = 'metadata'; | |||
| video.src = url; | |||
| await new Promise<void>((resolve, reject) => { | |||
| video.addEventListener('loadedmetadata', () => resolve(), { once: true }); | |||
| video.addEventListener('error', () => reject(new Error('video load error')), { once: true }); | |||
| }); | |||
| // Seek slightly past 0 — at exactly 0 some codecs return a black frame | |||
| video.currentTime = Math.min(0.1, Math.max(0, video.duration / 10)); | |||
| await new Promise<void>((resolve, reject) => { | |||
| video.addEventListener('seeked', () => resolve(), { once: true }); | |||
| video.addEventListener('error', () => reject(new Error('video seek error')), { once: true }); | |||
| }); | |||
| const canvas = document.createElement('canvas'); | |||
| canvas.width = video.videoWidth; | |||
| canvas.height = video.videoHeight; | |||
| const ctx = canvas.getContext('2d'); | |||
| if (!ctx) return null; | |||
| ctx.drawImage(video, 0, 0); | |||
| return await new Promise<Blob | null>((resolve) => | |||
| canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.85) | |||
| ); | |||
| } finally { | |||
| URL.revokeObjectURL(url); | |||
| } | |||
| } | |||
| async function pdfToImageItems( | |||
| file: File, | |||
| onProgress: (page: number, total: number) => void | |||
| @@ -102,6 +140,10 @@ export default function AdminDashboard() { | |||
| if (!files || files.length === 0) return; | |||
| setUploading(prev => ({ ...prev, extraMedia: true })); | |||
| const startedWithoutCover = !isEditing?.imageUrl; | |||
| let pendingCover: string | null = null; | |||
| const canPromote = () => startedWithoutCover && !pendingCover; | |||
| const uploaded: MediaItem[] = []; | |||
| for (const file of Array.from(files)) { | |||
| try { | |||
| @@ -109,14 +151,46 @@ export default function AdminDashboard() { | |||
| const items = await pdfToImageItems(file, (page, total) => | |||
| setPdfProgress({ name: file.name, page, total }) | |||
| ); | |||
| uploaded.push(...items); | |||
| setPdfProgress(null); | |||
| if (items.length > 0 && canPromote()) { | |||
| // Promote the first PDF page to cover; skip it from the gallery to avoid duplication. | |||
| pendingCover = items[0].url; | |||
| uploaded.push(...items.slice(1)); | |||
| } else { | |||
| uploaded.push(...items); | |||
| } | |||
| } else { | |||
| const formData = new FormData(); | |||
| formData.append('file', file); | |||
| const res = await fetch('/api/upload', { method: 'POST', body: formData }); | |||
| const data = await res.json(); | |||
| if (data.url) uploaded.push({ url: data.url }); | |||
| if (!data.url) continue; | |||
| if (isVideoFile(file)) { | |||
| // Video always goes to the gallery so users can play it. | |||
| uploaded.push({ url: data.url }); | |||
| // If no cover yet, extract the first frame and use it as the cover. | |||
| if (canPromote()) { | |||
| try { | |||
| const blob = await extractVideoFrame(file); | |||
| if (blob) { | |||
| const baseName = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9-_]/g, '_'); | |||
| const posterUrl = await uploadBlobAsImage(blob, `${baseName}-poster.jpg`); | |||
| if (posterUrl) pendingCover = posterUrl; | |||
| } | |||
| } catch (err) { | |||
| console.warn('Could not extract video poster for', file.name, err); | |||
| } | |||
| } | |||
| } else { | |||
| // Plain image | |||
| if (canPromote()) { | |||
| // Promote to cover; skip the gallery to avoid duplication. | |||
| pendingCover = data.url; | |||
| } else { | |||
| uploaded.push({ url: data.url }); | |||
| } | |||
| } | |||
| } | |||
| } catch (err) { | |||
| console.error('Upload failed for', file.name, err); | |||
| @@ -127,6 +201,7 @@ export default function AdminDashboard() { | |||
| setIsEditing(prev => ({ | |||
| ...prev, | |||
| imageUrl: (startedWithoutCover && pendingCover) ? pendingCover : (prev?.imageUrl || ''), | |||
| extraMedia: [...(prev?.extraMedia || []), ...uploaded], | |||
| })); | |||
| setUploading(prev => ({ ...prev, extraMedia: false })); | |||