| @@ -6,6 +6,8 @@ import { Card, Portal, MediaItem } from '@/types'; | |||||
| const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url); | const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url); | ||||
| const isPdfFile = (file: File) => | const isPdfFile = (file: File) => | ||||
| file.type === 'application/pdf' || /\.pdf$/i.test(file.name); | 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> { | async function uploadBlobAsImage(blob: Blob, name: string): Promise<string | null> { | ||||
| const formData = new FormData(); | const formData = new FormData(); | ||||
| @@ -15,6 +17,42 @@ async function uploadBlobAsImage(blob: Blob, name: string): Promise<string | nul | |||||
| return data.url || null; | 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( | async function pdfToImageItems( | ||||
| file: File, | file: File, | ||||
| onProgress: (page: number, total: number) => void | onProgress: (page: number, total: number) => void | ||||
| @@ -102,6 +140,10 @@ export default function AdminDashboard() { | |||||
| if (!files || files.length === 0) return; | if (!files || files.length === 0) return; | ||||
| setUploading(prev => ({ ...prev, extraMedia: true })); | setUploading(prev => ({ ...prev, extraMedia: true })); | ||||
| const startedWithoutCover = !isEditing?.imageUrl; | |||||
| let pendingCover: string | null = null; | |||||
| const canPromote = () => startedWithoutCover && !pendingCover; | |||||
| const uploaded: MediaItem[] = []; | const uploaded: MediaItem[] = []; | ||||
| for (const file of Array.from(files)) { | for (const file of Array.from(files)) { | ||||
| try { | try { | ||||
| @@ -109,14 +151,46 @@ export default function AdminDashboard() { | |||||
| const items = await pdfToImageItems(file, (page, total) => | const items = await pdfToImageItems(file, (page, total) => | ||||
| setPdfProgress({ name: file.name, page, total }) | setPdfProgress({ name: file.name, page, total }) | ||||
| ); | ); | ||||
| uploaded.push(...items); | |||||
| setPdfProgress(null); | 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 { | } else { | ||||
| const formData = new FormData(); | const formData = new FormData(); | ||||
| formData.append('file', file); | formData.append('file', file); | ||||
| const res = await fetch('/api/upload', { method: 'POST', body: formData }); | const res = await fetch('/api/upload', { method: 'POST', body: formData }); | ||||
| const data = await res.json(); | 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) { | } catch (err) { | ||||
| console.error('Upload failed for', file.name, err); | console.error('Upload failed for', file.name, err); | ||||
| @@ -127,6 +201,7 @@ export default function AdminDashboard() { | |||||
| setIsEditing(prev => ({ | setIsEditing(prev => ({ | ||||
| ...prev, | ...prev, | ||||
| imageUrl: (startedWithoutCover && pendingCover) ? pendingCover : (prev?.imageUrl || ''), | |||||
| extraMedia: [...(prev?.extraMedia || []), ...uploaded], | extraMedia: [...(prev?.extraMedia || []), ...uploaded], | ||||
| })); | })); | ||||
| setUploading(prev => ({ ...prev, extraMedia: false })); | setUploading(prev => ({ ...prev, extraMedia: false })); | ||||