Add the Vite frontend, Express API server, and supporting configuration files so the app can run locally on a complete development stack. Made-with: Cursor
582 lines
25 KiB
TypeScript
582 lines
25 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useLang } from "@/contexts/LanguageContext";
|
|
import { isAdminSession, clearAdminSession } from "@/lib/adminSession";
|
|
import { toast } from "sonner";
|
|
import {
|
|
type CourseVideo,
|
|
fetchAllCourseVideos,
|
|
createCourseVideo,
|
|
deleteCourseVideo,
|
|
updateCourseVideoStatus,
|
|
} from "@/lib/api";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import {
|
|
Upload, Video, Trash2, ArrowLeft, Eye,
|
|
Plus, FileVideo, Clock, Users, Search, Filter,
|
|
CheckCircle2, AlertCircle, Sparkles
|
|
} from "lucide-react";
|
|
import heroBg from "@/assets/hero-bg.jpg";
|
|
import logo from "@/assets/logo.png";
|
|
|
|
interface VideoItem {
|
|
id: string;
|
|
title: string;
|
|
course: string;
|
|
courseKey: string;
|
|
module: string;
|
|
duration: string;
|
|
uploadDate: string;
|
|
status: "published" | "draft" | "processing";
|
|
views: number;
|
|
thumbnail: string;
|
|
videoUrl?: string;
|
|
}
|
|
|
|
function thumbForCourseKey(key: string): string {
|
|
const m: Record<string, string> = {
|
|
biz: "📊",
|
|
product: "💡",
|
|
marketing: "📢",
|
|
ops: "⚙️",
|
|
};
|
|
return m[key] || "🎞";
|
|
}
|
|
|
|
function mapCourseVideoToItem(v: CourseVideo, lang: "en" | "zh"): VideoItem {
|
|
return {
|
|
id: v.id,
|
|
title: v.title,
|
|
course: lang === "zh" ? v.courseLabelZh : v.courseLabelEn,
|
|
courseKey: v.courseKey,
|
|
module: v.moduleName,
|
|
duration: v.duration,
|
|
uploadDate: v.uploadDate,
|
|
status: v.status as VideoItem["status"],
|
|
views: v.views,
|
|
thumbnail: thumbForCourseKey(v.courseKey),
|
|
videoUrl: v.videoUrl,
|
|
};
|
|
}
|
|
|
|
const courseOptions = [
|
|
{ key: "biz", label: { zh: "商业模式设计", en: "Business Model Design" } },
|
|
{ key: "product", label: { zh: "产品开发方法论", en: "Product Development" } },
|
|
{ key: "marketing", label: { zh: "营销策略", en: "Marketing Strategy" } },
|
|
{ key: "ops", label: { zh: "运营管理体系", en: "Operations Management" } },
|
|
];
|
|
|
|
export default function Admin() {
|
|
const { lang } = useLang();
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
if (!isAdminSession()) {
|
|
navigate("/admin", { replace: true });
|
|
}
|
|
}, [navigate]);
|
|
|
|
const [videos, setVideos] = useState<VideoItem[]>([]);
|
|
const [listLoading, setListLoading] = useState(true);
|
|
const [showUpload, setShowUpload] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [filterCourse, setFilterCourse] = useState("all");
|
|
const [dragActive, setDragActive] = useState(false);
|
|
|
|
const [uploadTitle, setUploadTitle] = useState("");
|
|
const [uploadCourse, setUploadCourse] = useState("biz");
|
|
const [uploadModule, setUploadModule] = useState("");
|
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
|
const [uploadBusy, setUploadBusy] = useState(false);
|
|
|
|
const refreshVideos = useCallback(async () => {
|
|
setListLoading(true);
|
|
try {
|
|
const list = await fetchAllCourseVideos();
|
|
setVideos(list.map((v) => mapCourseVideoToItem(v, lang)));
|
|
} catch {
|
|
toast.error(lang === "zh" ? "无法加载视频列表" : "Could not load videos");
|
|
} finally {
|
|
setListLoading(false);
|
|
}
|
|
}, [lang]);
|
|
|
|
useEffect(() => {
|
|
refreshVideos();
|
|
}, [refreshVideos]);
|
|
|
|
const resetUploadForm = () => {
|
|
setUploadTitle("");
|
|
setUploadCourse("biz");
|
|
setUploadModule(lang === "zh" ? "模块1" : "Module 1");
|
|
setUploadFile(null);
|
|
};
|
|
|
|
const buildUploadFormData = (status: "published" | "draft"): FormData => {
|
|
const co = courseOptions.find((c) => c.key === uploadCourse);
|
|
const fd = new FormData();
|
|
fd.append("file", uploadFile as File);
|
|
fd.append("title", uploadTitle.trim());
|
|
fd.append("courseKey", uploadCourse);
|
|
fd.append("courseLabelZh", co?.label.zh ?? "");
|
|
fd.append("courseLabelEn", co?.label.en ?? "");
|
|
fd.append("moduleName", uploadModule || (lang === "zh" ? "模块1" : "Module 1"));
|
|
fd.append("status", status);
|
|
return fd;
|
|
};
|
|
|
|
const handleSaveAsDraft = async () => {
|
|
if (!uploadFile) {
|
|
toast.error(lang === "zh" ? "请选择视频文件" : "Choose a video file");
|
|
return;
|
|
}
|
|
setUploadBusy(true);
|
|
try {
|
|
const r = await createCourseVideo(buildUploadFormData("draft"));
|
|
if (!r.ok) {
|
|
toast.error(r.error || (lang === "zh" ? "保存失败" : "Save failed"));
|
|
return;
|
|
}
|
|
toast.success(lang === "zh" ? "已保存草稿" : "Draft saved");
|
|
setShowUpload(false);
|
|
resetUploadForm();
|
|
await refreshVideos();
|
|
} catch {
|
|
toast.error(lang === "zh" ? "上传失败" : "Upload failed");
|
|
} finally {
|
|
setUploadBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleUpload = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!uploadFile) {
|
|
toast.error(lang === "zh" ? "请选择视频文件" : "Choose a video file");
|
|
return;
|
|
}
|
|
setUploadBusy(true);
|
|
try {
|
|
const r = await createCourseVideo(buildUploadFormData("published"));
|
|
if (!r.ok) {
|
|
toast.error(r.error || (lang === "zh" ? "发布失败" : "Publish failed"));
|
|
return;
|
|
}
|
|
toast.success(lang === "zh" ? "已发布,学员可在对应课程页观看" : "Published — visible on the course page");
|
|
setShowUpload(false);
|
|
resetUploadForm();
|
|
await refreshVideos();
|
|
} catch {
|
|
toast.error(lang === "zh" ? "上传失败" : "Upload failed");
|
|
} finally {
|
|
setUploadBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
const ok = await deleteCourseVideo(id);
|
|
if (!ok) {
|
|
toast.error(lang === "zh" ? "删除失败" : "Delete failed");
|
|
return;
|
|
}
|
|
setVideos((prev) => prev.filter((v) => v.id !== id));
|
|
toast.success(lang === "zh" ? "已删除" : "Deleted");
|
|
};
|
|
|
|
const handlePublish = async (id: string) => {
|
|
const r = await updateCourseVideoStatus(id, "published");
|
|
if (!r.ok) {
|
|
toast.error(lang === "zh" ? "发布失败" : "Publish failed");
|
|
return;
|
|
}
|
|
toast.success(lang === "zh" ? "已发布" : "Published");
|
|
await refreshVideos();
|
|
};
|
|
|
|
const filteredVideos = videos.filter(v => {
|
|
const matchSearch = v.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
v.course.toLowerCase().includes(searchQuery.toLowerCase());
|
|
const matchCourse = filterCourse === "all" || v.courseKey === filterCourse;
|
|
return matchSearch && matchCourse;
|
|
});
|
|
|
|
const statusConfig = {
|
|
published: { color: "text-emerald-600 bg-emerald-500/10", icon: CheckCircle2, label: { zh: "已发布", en: "Published" } },
|
|
draft: { color: "text-amber-600 bg-amber-500/10", icon: AlertCircle, label: { zh: "草稿", en: "Draft" } },
|
|
processing: { color: "text-blue-600 bg-blue-500/10", icon: Clock, label: { zh: "处理中", en: "Processing" } },
|
|
};
|
|
|
|
// Admin Dashboard
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
{/* Header */}
|
|
<div className="relative">
|
|
<img src={heroBg} alt="" className="absolute inset-0 w-full h-full object-cover" />
|
|
<div className="absolute inset-0 bg-gradient-to-b from-background/20 to-background" />
|
|
<div className="relative z-10 max-w-6xl mx-auto px-4 pt-4 pb-16">
|
|
<nav className="glass-nav rounded-2xl px-5 py-2.5 flex items-center justify-between mb-8">
|
|
<div className="flex items-center gap-2">
|
|
<img src={logo} alt="有维商学" className="w-7 h-7 rounded-xl object-contain" />
|
|
<span className="font-semibold text-foreground text-sm">
|
|
{lang === "zh" ? "管理后台" : "Admin Dashboard"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="section-tag !py-1">
|
|
<Sparkles className="w-3 h-3" />
|
|
Admin
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
clearAdminSession();
|
|
navigate("/home");
|
|
}}
|
|
className="glass-pill px-4 py-1.5 text-xs font-medium text-foreground hover:scale-105 transition-transform"
|
|
>
|
|
<ArrowLeft className="w-3 h-3 inline mr-1" />
|
|
{lang === "zh" ? "返回前台" : "Back to Site"}
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{[
|
|
{ label: lang === "zh" ? "总视频数" : "Total Videos", value: videos.length, icon: Video, color: "from-primary to-accent" },
|
|
{ label: lang === "zh" ? "已发布" : "Published", value: videos.filter(v => v.status === "published").length, icon: CheckCircle2, color: "from-emerald-500 to-teal-400" },
|
|
{ label: lang === "zh" ? "草稿" : "Drafts", value: videos.filter(v => v.status === "draft").length, icon: AlertCircle, color: "from-amber-500 to-orange-400" },
|
|
{ label: lang === "zh" ? "总播放量" : "Total Views", value: videos.reduce((sum, v) => sum + v.views, 0).toLocaleString(), icon: Users, color: "from-violet-500 to-purple-400" },
|
|
].map((stat, i) => (
|
|
<motion.div
|
|
key={i}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: i * 0.1 }}
|
|
className="glass-card p-5"
|
|
>
|
|
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${stat.color} flex items-center justify-center text-white mb-3`}>
|
|
<stat.icon className="w-5 h-5" />
|
|
</div>
|
|
<p className="text-2xl font-bold text-foreground">{stat.value}</p>
|
|
<p className="text-xs text-muted-foreground mt-1">{stat.label}</p>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="max-w-6xl mx-auto px-4 -mt-6 pb-16">
|
|
{/* Toolbar */}
|
|
<div className="glass-card rounded-2xl p-4 mb-6 flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={lang === "zh" ? "搜索视频..." : "Search videos..."}
|
|
className="w-full h-10 rounded-xl border border-border/50 bg-muted/20 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground" />
|
|
<select
|
|
value={filterCourse}
|
|
onChange={(e) => setFilterCourse(e.target.value)}
|
|
className="h-10 rounded-xl border border-border/50 bg-muted/20 pl-8 pr-4 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all appearance-none cursor-pointer"
|
|
>
|
|
<option value="all">{lang === "zh" ? "全部课程" : "All Courses"}</option>
|
|
{courseOptions.map(c => (
|
|
<option key={c.key} value={c.key}>{c.label[lang]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<motion.button
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={() => {
|
|
resetUploadForm();
|
|
setShowUpload(true);
|
|
}}
|
|
className="h-10 px-5 rounded-xl bg-gradient-to-r from-primary to-accent text-primary-foreground text-sm font-medium shadow-lg shadow-primary/20 flex items-center gap-2"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
{lang === "zh" ? "上传视频" : "Upload Video"}
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Video Grid */}
|
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{listLoading && (
|
|
<div className="col-span-full glass-card p-12 text-center text-muted-foreground text-sm">
|
|
{lang === "zh" ? "加载中…" : "Loading…"}
|
|
</div>
|
|
)}
|
|
{!listLoading && filteredVideos.map((video, i) => {
|
|
const status = statusConfig[video.status];
|
|
return (
|
|
<motion.div
|
|
key={video.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: i * 0.05 }}
|
|
className="glass-card overflow-hidden group"
|
|
whileHover={{ y: -4 }}
|
|
>
|
|
{/* 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>
|
|
<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 }}
|
|
className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<FileVideo className="w-5 h-5 text-primary-foreground" />
|
|
</motion.div>
|
|
</div>
|
|
<span className="absolute bottom-2 right-2 glass-pill px-2 py-0.5 text-[10px] font-medium text-foreground">
|
|
{video.duration}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="p-4">
|
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
<h4 className="text-sm font-semibold text-foreground line-clamp-2">{video.title}</h4>
|
|
<button
|
|
onClick={() => handleDelete(video.id)}
|
|
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground mb-3">
|
|
{video.course} · {video.module}
|
|
</p>
|
|
|
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${status.color}`}>
|
|
<status.icon className="w-3 h-3" />
|
|
{status.label[lang]}
|
|
</span>
|
|
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<Eye className="w-3 h-3" /> {video.views}
|
|
</span>
|
|
<span>{video.uploadDate}</span>
|
|
</div>
|
|
</div>
|
|
{(video.status === "draft" || video.status === "processing") && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handlePublish(video.id)}
|
|
className="mt-3 w-full h-9 rounded-lg text-xs font-medium bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-500/25 transition-colors"
|
|
>
|
|
{lang === "zh" ? "发布到学员端" : "Publish to learners"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{!listLoading && filteredVideos.length === 0 && (
|
|
<div className="glass-card p-12 text-center">
|
|
<Video className="w-12 h-12 text-muted-foreground/30 mx-auto mb-4" />
|
|
<p className="text-muted-foreground">
|
|
{lang === "zh" ? "暂无视频" : "No videos found"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Upload Modal */}
|
|
<AnimatePresence>
|
|
{showUpload && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
|
|
onClick={() => {
|
|
setShowUpload(false);
|
|
resetUploadForm();
|
|
}}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.9, opacity: 0 }}
|
|
className="w-full max-w-lg p-8 rounded-2xl border border-white/30 bg-white/80 dark:bg-white/15 backdrop-blur-2xl shadow-2xl shadow-primary/10"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-lg font-bold text-foreground mb-6 flex items-center gap-2">
|
|
<Upload className="w-5 h-5 text-primary" />
|
|
{lang === "zh" ? "上传新视频" : "Upload New Video"}
|
|
</h3>
|
|
|
|
<form onSubmit={handleUpload} className="space-y-4">
|
|
{/* Video Title */}
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-foreground">
|
|
{lang === "zh" ? "视频标题" : "Video Title"}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={uploadTitle}
|
|
onChange={(e) => setUploadTitle(e.target.value)}
|
|
placeholder={lang === "zh" ? "输入视频标题" : "Enter video title"}
|
|
className="w-full h-11 rounded-xl border border-border bg-background px-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 shadow-sm transition-all"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Course Selection */}
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-foreground">
|
|
{lang === "zh" ? "所属课程" : "Course"}
|
|
</label>
|
|
<select
|
|
value={uploadCourse}
|
|
onChange={(e) => setUploadCourse(e.target.value)}
|
|
className="w-full h-11 rounded-xl border border-border bg-background px-4 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 shadow-sm transition-all"
|
|
>
|
|
{courseOptions.map(c => (
|
|
<option key={c.key} value={c.key}>{c.label[lang]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Module */}
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-foreground">
|
|
{lang === "zh" ? "所属模块" : "Module"}
|
|
</label>
|
|
<select
|
|
value={uploadModule}
|
|
onChange={(e) => setUploadModule(e.target.value)}
|
|
className="w-full h-11 rounded-xl border border-border bg-background px-4 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 shadow-sm transition-all"
|
|
>
|
|
{[1, 2, 3, 4, 5, 6].map(n => (
|
|
<option key={n} value={lang === "zh" ? `模块${n}` : `Module ${n}`}>
|
|
{lang === "zh" ? `模块${n}` : `Module ${n}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* File Upload */}
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-foreground">
|
|
{lang === "zh" ? "视频文件" : "Video File"}
|
|
</label>
|
|
<div
|
|
onDragOver={(e) => { e.preventDefault(); setDragActive(true); }}
|
|
onDragLeave={() => setDragActive(false)}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
setDragActive(false);
|
|
if (e.dataTransfer.files[0]) setUploadFile(e.dataTransfer.files[0]);
|
|
}}
|
|
className={`relative border-2 border-dashed rounded-xl p-6 text-center transition-all cursor-pointer ${
|
|
dragActive
|
|
? "border-primary bg-primary/10"
|
|
: "border-border hover:border-primary/40 bg-background shadow-sm"
|
|
}`}
|
|
>
|
|
<input
|
|
type="file"
|
|
accept="video/*"
|
|
onChange={(e) => e.target.files?.[0] && setUploadFile(e.target.files[0])}
|
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
|
/>
|
|
{uploadFile ? (
|
|
<div className="flex items-center justify-center gap-2">
|
|
<FileVideo className="w-5 h-5 text-primary" />
|
|
<span className="text-sm text-foreground font-medium">{uploadFile.name}</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Upload className="w-8 h-8 text-muted-foreground/40 mx-auto mb-2" />
|
|
<p className="text-sm text-muted-foreground">
|
|
{lang === "zh" ? "拖拽文件到这里或点击上传" : "Drag & drop or click to upload"}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground/60 mt-1">MP4, MOV, AVI (max 2GB)</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Draft Preview */}
|
|
{(uploadTitle || uploadFile) && (
|
|
<div className="rounded-xl border border-border bg-muted/30 p-4 space-y-2">
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
{lang === "zh" ? "📋 上传预览" : "📋 Upload Preview"}
|
|
</p>
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-20 h-14 rounded-lg bg-gradient-to-br from-muted/60 to-muted/30 flex items-center justify-center text-2xl shrink-0">
|
|
{courseOptions.find(c => c.key === uploadCourse)?.label.zh.charAt(0) === "商" ? "📊" : "📋"}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold text-foreground truncate">
|
|
{uploadTitle || (lang === "zh" ? "未命名视频" : "Untitled Video")}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{courseOptions.find(c => c.key === uploadCourse)?.label[lang]} · {uploadModule || (lang === "zh" ? "模块1" : "Module 1")}
|
|
</p>
|
|
{uploadFile && (
|
|
<p className="text-xs text-primary mt-1 flex items-center gap-1">
|
|
<FileVideo className="w-3 h-3" />
|
|
{uploadFile.name} ({(uploadFile.size / 1024 / 1024).toFixed(1)} MB)
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3 pt-2">
|
|
<button
|
|
type="button"
|
|
disabled={uploadBusy}
|
|
onClick={() => {
|
|
setShowUpload(false);
|
|
resetUploadForm();
|
|
}}
|
|
className="h-11 px-4 rounded-xl border border-border bg-background text-sm font-medium text-foreground hover:bg-muted/40 shadow-sm transition-colors disabled:opacity-50"
|
|
>
|
|
{lang === "zh" ? "取消" : "Cancel"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={uploadBusy}
|
|
onClick={handleSaveAsDraft}
|
|
className="flex-1 h-11 rounded-xl border border-amber-500/50 bg-amber-500/10 text-sm font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-500/20 shadow-sm transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
|
|
>
|
|
<AlertCircle className="w-4 h-4" />
|
|
{lang === "zh" ? "保存草稿" : "Save as Draft"}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={uploadBusy}
|
|
className="flex-1 h-11 rounded-xl bg-gradient-to-r from-primary to-accent text-primary-foreground text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-xl transition-all flex items-center justify-center gap-2 disabled:opacity-50"
|
|
>
|
|
<Upload className="w-4 h-4" />
|
|
{uploadBusy ? (lang === "zh" ? "上传中…" : "Uploading…") : lang === "zh" ? "上传发布" : "Publish"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|