From 6a7847c24973cc7e92511599843dbcd8eeb8684b Mon Sep 17 00:00:00 2001 From: Ebenezer Date: Wed, 15 Apr 2026 18:05:22 +0800 Subject: [PATCH] done with videos --- server/courseVideos.ts | 6 +- src/pages/Admin.tsx | 233 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 227 insertions(+), 12 deletions(-) diff --git a/server/courseVideos.ts b/server/courseVideos.ts index b7342d4..e833b4f 100644 --- a/server/courseVideos.ts +++ b/server/courseVideos.ts @@ -135,6 +135,8 @@ export function registerCourseVideoRoutes(app: Express, getPool: () => mysql.Poo const moduleName = String(req.body.moduleName || "").trim(); const statusRaw = String(req.body.status || "published").trim().toLowerCase(); const status = statusRaw === "draft" ? "draft" : "published"; + const durationRaw = String(req.body.duration || "").trim(); + const duration = /^\d{2}:\d{2}(:\d{2})?$/.test(durationRaw) ? durationRaw : "00:00"; if (!title || !courseKey) { fs.unlink(file.path, () => {}); @@ -147,8 +149,8 @@ export function registerCourseVideoRoutes(app: Express, getPool: () => mysql.Poo await pool.execute( `INSERT INTO ${TABLE} (id, title, course_key, course_label_zh, course_label_en, module_name, file_relpath, status, views, duration) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, '00:00')`, - [id, title, courseKey, courseLabelZh || courseKey, courseLabelEn || courseKey, moduleName, rel, status], + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`, + [id, title, courseKey, courseLabelZh || courseKey, courseLabelEn || courseKey, moduleName, rel, status, duration], ); const [rows] = await pool.query(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]); diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 6bebd06..207807c 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -9,12 +9,13 @@ import { createCourseVideo, deleteCourseVideo, updateCourseVideoStatus, + mediaUrl, } from "@/lib/api"; import { motion, AnimatePresence } from "framer-motion"; import { Upload, Video, Trash2, ArrowLeft, Eye, Plus, FileVideo, Clock, Users, Search, Filter, - CheckCircle2, AlertCircle, Sparkles + CheckCircle2, AlertCircle, Sparkles, Play, X } from "lucide-react"; import heroBg from "@/assets/hero-bg.jpg"; import logo from "@/assets/logo.png"; @@ -33,6 +34,11 @@ interface VideoItem { videoUrl?: string; } +interface VideoMeta { + thumbnailDataUrl: string; + seconds: number; +} + function thumbForCourseKey(key: string): string { const m: Record = { biz: "πŸ“Š", @@ -43,6 +49,72 @@ function thumbForCourseKey(key: string): string { return m[key] || "🎞"; } +function formatDuration(seconds: number): string { + const s = Math.max(0, Math.floor(seconds || 0)); + const hh = Math.floor(s / 3600); + const mm = Math.floor((s % 3600) / 60); + const ss = s % 60; + if (hh > 0) { + return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; + } + return `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; +} + +async function readVideoDurationFromFile(file: File): Promise { + return new Promise((resolve, reject) => { + const video = document.createElement("video"); + const url = URL.createObjectURL(file); + video.preload = "metadata"; + video.src = url; + video.onloadedmetadata = () => { + URL.revokeObjectURL(url); + resolve(Number.isFinite(video.duration) ? video.duration : 0); + }; + video.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Could not read video duration")); + }; + }); +} + +async function extractVideoMeta(videoUrl: string): Promise { + return new Promise((resolve, reject) => { + const video = document.createElement("video"); + video.preload = "metadata"; + video.muted = true; + video.playsInline = true; + video.src = mediaUrl(videoUrl); + + video.onloadedmetadata = () => { + const seconds = Number.isFinite(video.duration) ? video.duration : 0; + const seekTarget = Math.min(1, Math.max(0, seconds / 3)); + video.currentTime = seekTarget; + }; + + video.onseeked = () => { + try { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth || 640; + canvas.height = video.videoHeight || 360; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("No canvas context")); + return; + } + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + resolve({ + thumbnailDataUrl: canvas.toDataURL("image/jpeg", 0.82), + seconds: Number.isFinite(video.duration) ? video.duration : 0, + }); + } catch (err) { + reject(err instanceof Error ? err : new Error("Could not capture thumbnail")); + } + }; + + video.onerror = () => reject(new Error("Could not load video")); + }); +} + function mapCourseVideoToItem(v: CourseVideo, lang: "en" | "zh"): VideoItem { return { id: v.id, @@ -87,7 +159,10 @@ export default function Admin() { const [uploadCourse, setUploadCourse] = useState("biz"); const [uploadModule, setUploadModule] = useState(""); const [uploadFile, setUploadFile] = useState(null); + const [uploadDurationSeconds, setUploadDurationSeconds] = useState(null); const [uploadBusy, setUploadBusy] = useState(false); + const [previewVideo, setPreviewVideo] = useState(null); + const [videoMetaById, setVideoMetaById] = useState>({}); const refreshVideos = useCallback(async () => { setListLoading(true); @@ -105,13 +180,57 @@ export default function Admin() { refreshVideos(); }, [refreshVideos]); + useEffect(() => { + let cancelled = false; + const pending = videos.filter((v) => v.videoUrl && !videoMetaById[v.id]); + if (pending.length === 0) return; + + (async () => { + for (const v of pending) { + if (cancelled || !v.videoUrl) break; + try { + const meta = await extractVideoMeta(v.videoUrl); + if (cancelled) return; + setVideoMetaById((prev) => { + if (prev[v.id]) return prev; + return { ...prev, [v.id]: meta }; + }); + } catch { + // Keep fallback icon + stored duration when metadata extraction fails. + } + } + })(); + + return () => { + cancelled = true; + }; + }, [videos, videoMetaById]); + const resetUploadForm = () => { setUploadTitle(""); setUploadCourse("biz"); setUploadModule(lang === "zh" ? "樑块1" : "Module 1"); setUploadFile(null); + setUploadDurationSeconds(null); }; + const pickUploadFile = useCallback( + async (file: File | null) => { + setUploadFile(file); + if (!file) { + setUploadDurationSeconds(null); + return; + } + try { + const seconds = await readVideoDurationFromFile(file); + setUploadDurationSeconds(seconds); + } catch { + setUploadDurationSeconds(null); + } + }, + [], + ); + const buildUploadFormData = (status: "published" | "draft"): FormData => { const co = courseOptions.find((c) => c.key === uploadCourse); const fd = new FormData(); @@ -122,6 +241,9 @@ export default function Admin() { fd.append("courseLabelEn", co?.label.en ?? ""); fd.append("moduleName", uploadModule || (lang === "zh" ? "樑块1" : "Module 1")); fd.append("status", status); + if (uploadDurationSeconds !== null) { + fd.append("duration", formatDuration(uploadDurationSeconds)); + } return fd; }; @@ -316,6 +438,7 @@ export default function Admin() { )} {!listLoading && filteredVideos.map((video, i) => { const status = statusConfig[video.status]; + const videoMeta = videoMetaById[video.id]; return ( {/* Thumbnail */}
- {video.thumbnail} + {videoMeta?.thumbnailDataUrl ? ( + {video.title} + ) : ( + {video.thumbnail} + )}
- { + if (!video.videoUrl) { + toast.error(lang === "zh" ? "θ―₯θ§†ι’‘ζš‚ζ— ε―ζ’­ζ”Ύεœ°ε€" : "No playable video URL"); + return; + } + setPreviewVideo(video); + }} className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" + aria-label={lang === "zh" ? "播放视钑" : "Play video"} > - - + +
- {video.duration} + {videoMeta ? formatDuration(videoMeta.seconds) : video.duration}
@@ -370,6 +508,20 @@ export default function Admin() { {video.uploadDate} + {(video.status === "draft" || video.status === "processing") && ( + +
+ +
+
+ + )} + ); }