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 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]);
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user