done with videos
This commit is contained in:
@@ -135,6 +135,8 @@ export function registerCourseVideoRoutes(app: Express, getPool: () => mysql.Poo
|
|||||||
const moduleName = String(req.body.moduleName || "").trim();
|
const moduleName = String(req.body.moduleName || "").trim();
|
||||||
const statusRaw = String(req.body.status || "published").trim().toLowerCase();
|
const statusRaw = String(req.body.status || "published").trim().toLowerCase();
|
||||||
const status = statusRaw === "draft" ? "draft" : "published";
|
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) {
|
if (!title || !courseKey) {
|
||||||
fs.unlink(file.path, () => {});
|
fs.unlink(file.path, () => {});
|
||||||
@@ -147,8 +149,8 @@ export function registerCourseVideoRoutes(app: Express, getPool: () => mysql.Poo
|
|||||||
|
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
`INSERT INTO ${TABLE} (id, title, course_key, course_label_zh, course_label_en, module_name, file_relpath, status, views, duration)
|
`INSERT INTO ${TABLE} (id, title, course_key, course_label_zh, course_label_en, module_name, file_relpath, status, views, duration)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, '00:00')`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`,
|
||||||
[id, title, courseKey, courseLabelZh || courseKey, courseLabelEn || courseKey, moduleName, rel, status],
|
[id, title, courseKey, courseLabelZh || courseKey, courseLabelEn || courseKey, moduleName, rel, status, duration],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [rows] = await pool.query<RowDataPacket[]>(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]);
|
const [rows] = await pool.query<RowDataPacket[]>(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]);
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import {
|
|||||||
createCourseVideo,
|
createCourseVideo,
|
||||||
deleteCourseVideo,
|
deleteCourseVideo,
|
||||||
updateCourseVideoStatus,
|
updateCourseVideoStatus,
|
||||||
|
mediaUrl,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Upload, Video, Trash2, ArrowLeft, Eye,
|
Upload, Video, Trash2, ArrowLeft, Eye,
|
||||||
Plus, FileVideo, Clock, Users, Search, Filter,
|
Plus, FileVideo, Clock, Users, Search, Filter,
|
||||||
CheckCircle2, AlertCircle, Sparkles
|
CheckCircle2, AlertCircle, Sparkles, Play, X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import heroBg from "@/assets/hero-bg.jpg";
|
import heroBg from "@/assets/hero-bg.jpg";
|
||||||
import logo from "@/assets/logo.png";
|
import logo from "@/assets/logo.png";
|
||||||
@@ -33,6 +34,11 @@ interface VideoItem {
|
|||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VideoMeta {
|
||||||
|
thumbnailDataUrl: string;
|
||||||
|
seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
function thumbForCourseKey(key: string): string {
|
function thumbForCourseKey(key: string): string {
|
||||||
const m: Record<string, string> = {
|
const m: Record<string, string> = {
|
||||||
biz: "📊",
|
biz: "📊",
|
||||||
@@ -43,6 +49,72 @@ function thumbForCourseKey(key: string): string {
|
|||||||
return m[key] || "🎞";
|
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 {
|
function mapCourseVideoToItem(v: CourseVideo, lang: "en" | "zh"): VideoItem {
|
||||||
return {
|
return {
|
||||||
id: v.id,
|
id: v.id,
|
||||||
@@ -87,7 +159,10 @@ export default function Admin() {
|
|||||||
const [uploadCourse, setUploadCourse] = useState("biz");
|
const [uploadCourse, setUploadCourse] = useState("biz");
|
||||||
const [uploadModule, setUploadModule] = useState("");
|
const [uploadModule, setUploadModule] = useState("");
|
||||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
|
const [uploadDurationSeconds, setUploadDurationSeconds] = useState<number | null>(null);
|
||||||
const [uploadBusy, setUploadBusy] = useState(false);
|
const [uploadBusy, setUploadBusy] = useState(false);
|
||||||
|
const [previewVideo, setPreviewVideo] = useState<VideoItem | null>(null);
|
||||||
|
const [videoMetaById, setVideoMetaById] = useState<Record<string, VideoMeta>>({});
|
||||||
|
|
||||||
const refreshVideos = useCallback(async () => {
|
const refreshVideos = useCallback(async () => {
|
||||||
setListLoading(true);
|
setListLoading(true);
|
||||||
@@ -105,13 +180,57 @@ export default function Admin() {
|
|||||||
refreshVideos();
|
refreshVideos();
|
||||||
}, [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 = () => {
|
const resetUploadForm = () => {
|
||||||
setUploadTitle("");
|
setUploadTitle("");
|
||||||
setUploadCourse("biz");
|
setUploadCourse("biz");
|
||||||
setUploadModule(lang === "zh" ? "模块1" : "Module 1");
|
setUploadModule(lang === "zh" ? "模块1" : "Module 1");
|
||||||
setUploadFile(null);
|
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 buildUploadFormData = (status: "published" | "draft"): FormData => {
|
||||||
const co = courseOptions.find((c) => c.key === uploadCourse);
|
const co = courseOptions.find((c) => c.key === uploadCourse);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
@@ -122,6 +241,9 @@ export default function Admin() {
|
|||||||
fd.append("courseLabelEn", co?.label.en ?? "");
|
fd.append("courseLabelEn", co?.label.en ?? "");
|
||||||
fd.append("moduleName", uploadModule || (lang === "zh" ? "模块1" : "Module 1"));
|
fd.append("moduleName", uploadModule || (lang === "zh" ? "模块1" : "Module 1"));
|
||||||
fd.append("status", status);
|
fd.append("status", status);
|
||||||
|
if (uploadDurationSeconds !== null) {
|
||||||
|
fd.append("duration", formatDuration(uploadDurationSeconds));
|
||||||
|
}
|
||||||
return fd;
|
return fd;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -316,6 +438,7 @@ export default function Admin() {
|
|||||||
)}
|
)}
|
||||||
{!listLoading && filteredVideos.map((video, i) => {
|
{!listLoading && filteredVideos.map((video, i) => {
|
||||||
const status = statusConfig[video.status];
|
const status = statusConfig[video.status];
|
||||||
|
const videoMeta = videoMetaById[video.id];
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={video.id}
|
key={video.id}
|
||||||
@@ -327,18 +450,33 @@ export default function Admin() {
|
|||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div className="aspect-video bg-gradient-to-br from-muted/50 to-muted/30 flex items-center justify-center relative">
|
<div className="aspect-video bg-gradient-to-br from-muted/50 to-muted/30 flex items-center justify-center relative">
|
||||||
<span className="text-4xl">{video.thumbnail}</span>
|
{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">
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||||
<motion.div
|
<button
|
||||||
initial={{ opacity: 0, scale: 0.5 }}
|
type="button"
|
||||||
whileHover={{ opacity: 1, scale: 1 }}
|
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"
|
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" />
|
<Play className="w-5 h-5 text-primary-foreground" />
|
||||||
</motion.div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="absolute bottom-2 right-2 glass-pill px-2 py-0.5 text-[10px] font-medium text-foreground">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -370,6 +508,20 @@ export default function Admin() {
|
|||||||
<span>{video.uploadDate}</span>
|
<span>{video.uploadDate}</span>
|
||||||
</div>
|
</div>
|
||||||
</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") && (
|
{(video.status === "draft" || video.status === "processing") && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -481,7 +633,9 @@ export default function Admin() {
|
|||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragActive(false);
|
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 ${
|
className={`relative border-2 border-dashed rounded-xl p-6 text-center transition-all cursor-pointer ${
|
||||||
dragActive
|
dragActive
|
||||||
@@ -492,7 +646,9 @@ export default function Admin() {
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="video/*"
|
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"
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
{uploadFile ? (
|
{uploadFile ? (
|
||||||
@@ -535,6 +691,11 @@ export default function Admin() {
|
|||||||
{uploadFile.name} ({(uploadFile.size / 1024 / 1024).toFixed(1)} MB)
|
{uploadFile.name} ({(uploadFile.size / 1024 / 1024).toFixed(1)} MB)
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{uploadDurationSeconds !== null && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{lang === "zh" ? "时长" : "Duration"}: {formatDuration(uploadDurationSeconds)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -576,6 +737,58 @@ export default function Admin() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user