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.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") && (
)}
+ {uploadDurationSeconds !== null && (
+
+ {lang === "zh" ? "ζΆιΏ" : "Duration"}: {formatDuration(uploadDurationSeconds)}
+
+ )}
@@ -576,6 +737,58 @@ export default function Admin() {
)}
+
+ {/* Video Preview Modal */}
+
+ {previewVideo && (
+ setPreviewVideo(null)}
+ >
+ e.stopPropagation()}
+ >
+
+
+
{previewVideo.title}
+
+ {previewVideo.course} Β· {previewVideo.module}
+
+
+
+
+
+
+
+
+
+ )}
+
);
}