done with videos

This commit is contained in:
Ebenezer
2026-04-15 18:05:22 +08:00
parent 123f82e92c
commit 6a7847c249
2 changed files with 227 additions and 12 deletions

View File

@@ -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<RowDataPacket[]>(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]);

View File

@@ -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<string, string> = {
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<number> {
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<VideoMeta> {
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<File | null>(null);
const [uploadDurationSeconds, setUploadDurationSeconds] = useState<number | null>(null);
const [uploadBusy, setUploadBusy] = useState(false);
const [previewVideo, setPreviewVideo] = useState<VideoItem | null>(null);
const [videoMetaById, setVideoMetaById] = useState<Record<string, VideoMeta>>({});
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 (
<motion.div
key={video.id}
@@ -327,18 +450,33 @@ export default function Admin() {
>
{/* Thumbnail */}
<div className="aspect-video bg-gradient-to-br from-muted/50 to-muted/30 flex items-center justify-center relative">
{videoMeta?.thumbnailDataUrl ? (
<img
src={videoMeta.thumbnailDataUrl}
alt={video.title}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<span className="text-4xl">{video.thumbnail}</span>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
whileHover={{ opacity: 1, scale: 1 }}
<button
type="button"
onClick={() => {
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"}
>
<FileVideo className="w-5 h-5 text-primary-foreground" />
</motion.div>
<Play className="w-5 h-5 text-primary-foreground" />
</button>
</div>
<span className="absolute bottom-2 right-2 glass-pill px-2 py-0.5 text-[10px] font-medium text-foreground">
{video.duration}
{videoMeta ? formatDuration(videoMeta.seconds) : video.duration}
</span>
</div>
@@ -370,6 +508,20 @@ export default function Admin() {
<span>{video.uploadDate}</span>
</div>
</div>
<button
type="button"
onClick={() => {
if (!video.videoUrl) {
toast.error(lang === "zh" ? "该视频暂无可播放地址" : "No playable video URL");
return;
}
setPreviewVideo(video);
}}
className="mt-3 w-full h-9 rounded-lg text-xs font-medium bg-primary/12 text-primary hover:bg-primary/20 transition-colors flex items-center justify-center gap-2"
>
<FileVideo className="w-3.5 h-3.5" />
{lang === "zh" ? "查看视频" : "View Video"}
</button>
{(video.status === "draft" || video.status === "processing") && (
<button
type="button"
@@ -481,7 +633,9 @@ export default function Admin() {
onDrop={(e) => {
e.preventDefault();
setDragActive(false);
if (e.dataTransfer.files[0]) setUploadFile(e.dataTransfer.files[0]);
if (e.dataTransfer.files[0]) {
void pickUploadFile(e.dataTransfer.files[0]);
}
}}
className={`relative border-2 border-dashed rounded-xl p-6 text-center transition-all cursor-pointer ${
dragActive
@@ -492,7 +646,9 @@ export default function Admin() {
<input
type="file"
accept="video/*"
onChange={(e) => e.target.files?.[0] && setUploadFile(e.target.files[0])}
onChange={(e) => {
void pickUploadFile(e.target.files?.[0] || null);
}}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
{uploadFile ? (
@@ -535,6 +691,11 @@ export default function Admin() {
{uploadFile.name} ({(uploadFile.size / 1024 / 1024).toFixed(1)} MB)
</p>
)}
{uploadDurationSeconds !== null && (
<p className="text-xs text-muted-foreground mt-1">
{lang === "zh" ? "时长" : "Duration"}: {formatDuration(uploadDurationSeconds)}
</p>
)}
</div>
</div>
</div>
@@ -576,6 +737,58 @@ export default function Admin() {
</motion.div>
)}
</AnimatePresence>
{/* Video Preview Modal */}
<AnimatePresence>
{previewVideo && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"
onClick={() => setPreviewVideo(null)}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="w-full max-w-4xl rounded-2xl overflow-hidden border border-white/20 bg-black/80 backdrop-blur"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 bg-black/50 border-b border-white/10">
<div className="min-w-0">
<p className="text-sm font-semibold text-white truncate">{previewVideo.title}</p>
<p className="text-xs text-white/70">
{previewVideo.course} · {previewVideo.module}
</p>
</div>
<button
type="button"
className="p-2 rounded-lg text-white/80 hover:text-white hover:bg-white/10 transition-colors"
onClick={() => setPreviewVideo(null)}
aria-label={lang === "zh" ? "关闭" : "Close"}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="bg-black">
<video
key={previewVideo.id}
controls
autoPlay
preload="metadata"
className="w-full max-h-[75vh] bg-black"
src={mediaUrl(previewVideo.videoUrl || "")}
>
{lang === "zh"
? "当前浏览器不支持视频播放。"
: "Your browser does not support video playback."}
</video>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}