add
This commit is contained in:
parent
f1f7272405
commit
4ac8c07215
|
@ -49,7 +49,6 @@
|
|||
"hls.js": "^1.5.18",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"mitt": "^3.0.1",
|
||||
"plyr-react": "^5.3.0",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import { CourseCard } from "@web/src/components/models/course/card/CourseCard";
|
||||
import { CourseDetail } from "@web/src/components/models/course/detail/CourseDetailContent";
|
||||
import { CourseSyllabus } from "@web/src/components/models/course/detail/CourseSyllabus";
|
||||
|
||||
export const CoursePage = () => {
|
||||
// 假设这些数据从API获取
|
||||
const course: any = {
|
||||
/* course data */
|
||||
};
|
||||
const sections: any = [
|
||||
/* sections data */
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* 左侧课程详情 */}
|
||||
<div className="lg:col-span-2">
|
||||
<CourseDetail course={course} />
|
||||
</div>
|
||||
{/* 右侧课程大纲 */}
|
||||
<div className="space-y-4">
|
||||
<CourseCard course={course} />
|
||||
<CourseSyllabus
|
||||
sections={sections}
|
||||
onLectureClick={(lectureId) => {
|
||||
console.log("Clicked lecture:", lectureId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -52,7 +52,7 @@ export default function InstructorCoursesPage() {
|
|||
renderItem={(course) => (
|
||||
<CourseCard
|
||||
onClick={() => {
|
||||
navigate(`/course/${course.id}/detail`, {
|
||||
navigate(`/course/${course.id}/manage`, {
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Course } from "@nice/common";
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
import CourseDetailSkeleton from "./CourseDetailSkeleton";
|
||||
interface CourseDetailProps {
|
||||
course: Course;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const CourseDetailContent: React.FC<CourseDetailProps> = ({
|
||||
course,
|
||||
isLoading,
|
||||
}) => {
|
||||
if (isLoading || !course) {
|
||||
return <CourseDetailSkeleton />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 课程标题区域 */}
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">{course.title}</h1>
|
||||
{course.subTitle && (
|
||||
<p className="text-xl text-gray-600">{course.subTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 课程描述 */}
|
||||
<div className="prose max-w-none">
|
||||
<p>{course.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 学习目标 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">学习目标</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{course.objectives.map((objective, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span>{objective}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 适合人群 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">适合人群</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{course.audiences.map((audience, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-1" />
|
||||
<span>{audience}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 课程要求 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">课程要求</h2>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
||||
{course.requirements.map((requirement, index) => (
|
||||
<li key={index}>{requirement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 可获得技能 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">可获得技能</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{course.skills.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -9,6 +9,8 @@ interface CourseDetailContextType {
|
|||
selectedLectureId?: string | undefined;
|
||||
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
||||
isLoading?: boolean;
|
||||
isHeaderVisible: boolean; // 新增
|
||||
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
||||
}
|
||||
interface CourseFormProviderProps {
|
||||
children: ReactNode;
|
||||
|
@ -34,7 +36,7 @@ export function CourseDetailProvider({
|
|||
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增
|
||||
return (
|
||||
<CourseDetailContext.Provider
|
||||
value={{
|
||||
|
@ -43,6 +45,8 @@ export function CourseDetailProvider({
|
|||
selectedLectureId,
|
||||
setSelectedLectureId,
|
||||
isLoading,
|
||||
isHeaderVisible,
|
||||
setIsHeaderVisible,
|
||||
}}>
|
||||
{children}
|
||||
</CourseDetailContext.Provider>
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Course } from "@nice/common";
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
import CourseDetailSkeleton from "../CourseDetailSkeleton";
|
||||
import CourseDetailNavBar from "./CourseDetailNavBar";
|
||||
interface CourseDetailProps {
|
||||
course: Course;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const CourseDetailDescription: React.FC<CourseDetailProps> = ({
|
||||
course,
|
||||
isLoading,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<CourseDetailNavBar></CourseDetailNavBar>
|
||||
|
||||
<div className="w-[80%] mx-auto px-4 py-8">
|
||||
{isLoading || !course ? (
|
||||
<CourseDetailSkeleton />
|
||||
) : (
|
||||
<CourseDetailSkeleton />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,47 @@
|
|||
import { NavBar } from "@web/src/components/presentation/NavBar";
|
||||
import { HomeIcon, BellIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
DocumentTextIcon,
|
||||
MagnifyingGlassIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
export default function CourseDetailNavBar() {
|
||||
const navItems = [
|
||||
{
|
||||
id: "search",
|
||||
icon: <MagnifyingGlassIcon className="w-5 h-5" />,
|
||||
label: "搜索",
|
||||
},
|
||||
{
|
||||
id: "overview",
|
||||
icon: <HomeIcon className="w-5 h-5" />,
|
||||
label: "概述",
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
icon: <DocumentTextIcon className="w-5 h-5" />,
|
||||
label: "备注",
|
||||
},
|
||||
{
|
||||
id: "announcements",
|
||||
icon: <BellIcon className="w-5 h-5" />,
|
||||
label: "公告",
|
||||
},
|
||||
{
|
||||
id: "reviews",
|
||||
icon: <StarIcon className="w-5 h-5" />,
|
||||
label: "评价",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className=" bg-gray-50">
|
||||
<NavBar
|
||||
items={navItems}
|
||||
defaultSelected="overview"
|
||||
onSelect={(id) => console.log("Selected:", id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import { useContext } from "react";
|
||||
import { CourseDetailContext } from "../../CourseDetailContext";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export function Overview() {
|
||||
const { course } = useContext(CourseDetailContext);
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-8">
|
||||
{/* 课程描述 */}
|
||||
<div className="prose max-w-none">
|
||||
<p>{course?.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 学习目标 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">学习目标</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{course?.objectives.map((objective, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span>{objective}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 适合人群 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">适合人群</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{course?.audiences.map((audience, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-1" />
|
||||
<span>{audience}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 课程要求 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">课程要求</h2>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
||||
{course?.requirements.map((requirement, index) => (
|
||||
<li key={index}>{requirement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 可获得技能 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">可获得技能</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{course?.skills.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,23 +1,20 @@
|
|||
// components/CourseVideoPage.tsx
|
||||
// components/CourseDetailDisplayArea.tsx
|
||||
import { motion, useScroll, useTransform } from "framer-motion";
|
||||
import React from "react";
|
||||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||
import { CourseDetailContent } from "./CourseDetailContent";
|
||||
import { CourseDetailDescription } from "./CourseDetailDescription/CourseDetailDescription";
|
||||
import { Course } from "@nice/common";
|
||||
|
||||
interface CourseVideoPageProps {
|
||||
interface CourseDetailDisplayAreaProps {
|
||||
course: Course;
|
||||
videoSrc?: string;
|
||||
videoPoster?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const CourseVideoPage: React.FC<CourseVideoPageProps> = ({
|
||||
course,
|
||||
videoSrc,
|
||||
videoPoster,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
export const CourseDetailDisplayArea: React.FC<
|
||||
CourseDetailDisplayAreaProps
|
||||
> = ({ course, videoSrc, videoPoster, isLoading = false }) => {
|
||||
// 创建滚动动画效果
|
||||
const { scrollY } = useScroll();
|
||||
const videoScale = useTransform(scrollY, [0, 200], [1, 0.8]);
|
||||
|
@ -26,13 +23,16 @@ export const CourseVideoPage: React.FC<CourseVideoPageProps> = ({
|
|||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 固定的视频区域 */}
|
||||
{/* 移除 sticky 定位,让视频区域随页面滚动 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
style={{
|
||||
// scale: videoScale,
|
||||
opacity: videoOpacity,
|
||||
}}
|
||||
className="sticky top-0 w-full bg-black z-10">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
className="w-full bg-black">
|
||||
<div className=" w-full ">
|
||||
<VideoPlayer src={videoSrc} poster={videoPoster} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
@ -42,10 +42,13 @@ export const CourseVideoPage: React.FC<CourseVideoPageProps> = ({
|
|||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="max-w-6xl mx-auto px-4 py-8">
|
||||
<CourseDetailContent course={course} isLoading={isLoading} />
|
||||
className="w-full">
|
||||
<CourseDetailDescription
|
||||
course={course}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default CourseVideoPage;
|
||||
export default CourseDetailDisplayArea;
|
|
@ -0,0 +1,57 @@
|
|||
// components/Header.tsx
|
||||
import { motion, useScroll, useTransform } from "framer-motion";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { CourseDetailContext } from "../CourseDetailContext";
|
||||
|
||||
export const CourseDetailHeader = () => {
|
||||
const { scrollY } = useScroll();
|
||||
|
||||
const [lastScrollY, setLastScrollY] = useState(0);
|
||||
const { course, isHeaderVisible, setIsHeaderVisible } =
|
||||
useContext(CourseDetailContext);
|
||||
useEffect(() => {
|
||||
const updateHeader = () => {
|
||||
const current = scrollY.get();
|
||||
const direction = current > lastScrollY ? "down" : "up";
|
||||
|
||||
if (direction === "down" && current > 100) {
|
||||
setIsHeaderVisible(false);
|
||||
} else if (direction === "up") {
|
||||
setIsHeaderVisible(true);
|
||||
}
|
||||
|
||||
setLastScrollY(current);
|
||||
};
|
||||
|
||||
// 使用 requestAnimationFrame 来优化性能
|
||||
const unsubscribe = scrollY.on("change", () => {
|
||||
requestAnimationFrame(updateHeader);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [lastScrollY, scrollY, setIsHeaderVisible]);
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ y: 0 }}
|
||||
animate={{ y: isHeaderVisible ? 0 : -100 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="fixed top-0 left-0 w-full h-16 bg-slate-900 backdrop-blur-sm z-50 shadow-sm">
|
||||
<div className="w-full mx-auto px-4 h-full flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-white text-xl ">{course?.title}</h1>
|
||||
</div>
|
||||
<nav className="flex items-center space-x-4">
|
||||
{/* 添加你的导航项目 */}
|
||||
<button className="px-4 py-2 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors">
|
||||
开始学习
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</motion.header>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseDetailHeader;
|
|
@ -1,9 +1,10 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { useContext, useState } from "react";
|
||||
import { CourseDetailContext } from "./CourseDetailContext";
|
||||
import { CourseSyllabus } from "./CourseSyllabus";
|
||||
import { CourseDetailContent } from "./CourseDetailContent";
|
||||
import CourseVideoPage from "./CourseVideoPage";
|
||||
|
||||
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
||||
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
||||
import CourseDetailHeader from "./CourseDetailHeader/CourseDetailHeader";
|
||||
|
||||
export default function CourseDetailLayout() {
|
||||
const { course, selectedLectureId, isLoading, setSelectedLectureId } =
|
||||
|
@ -15,28 +16,33 @@ export default function CourseDetailLayout() {
|
|||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<CourseDetailHeader /> {/* 添加 Header 组件 */}
|
||||
{/* 主内容区域 */}
|
||||
<motion.div
|
||||
animate={{
|
||||
width: isSyllabusOpen ? "66.666667%" : "100%",
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="relative">
|
||||
<CourseVideoPage
|
||||
course={course}
|
||||
isLoading={isLoading}
|
||||
videoSrc="https://flipfit-cdn.akamaized.net/flip_hls/664ce52bd6fcda001911a88c-8f1c4d/video_h1.m3u8"
|
||||
videoPoster="https://picsum.photos/800/450"
|
||||
{/* 为了防止 Header 覆盖内容,添加上边距 */}
|
||||
<div className="pt-16">
|
||||
{" "}
|
||||
{/* 添加这个包装 div */}
|
||||
<motion.div
|
||||
animate={{
|
||||
width: isSyllabusOpen ? "75%" : "100%",
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="relative">
|
||||
<CourseDetailDisplayArea
|
||||
course={course}
|
||||
isLoading={isLoading}
|
||||
videoSrc="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"
|
||||
videoPoster="https://picsum.photos/800/450"
|
||||
/>
|
||||
</motion.div>
|
||||
{/* 课程大纲侧边栏 */}
|
||||
<CourseSyllabus
|
||||
sections={course?.sections || []}
|
||||
onLectureClick={handleLectureClick}
|
||||
isOpen={isSyllabusOpen}
|
||||
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 课程大纲侧边栏 */}
|
||||
<CourseSyllabus
|
||||
sections={course?.sections || []}
|
||||
onLectureClick={handleLectureClick}
|
||||
isOpen={isSyllabusOpen}
|
||||
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,214 +0,0 @@
|
|||
import { XMarkIcon, BookOpenIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
PlayCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import React, { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { SectionDto } from "@nice/common";
|
||||
|
||||
interface CourseSyllabusProps {
|
||||
sections: SectionDto[];
|
||||
onLectureClick?: (lectureId: string) => void;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||
sections,
|
||||
onLectureClick,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) => {
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>([]);
|
||||
const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections((prev) =>
|
||||
prev.includes(sectionId)
|
||||
? prev.filter((id) => id !== sectionId)
|
||||
: [...prev, sectionId]
|
||||
);
|
||||
|
||||
// 平滑滚动到选中的章节
|
||||
setTimeout(() => {
|
||||
sectionRefs.current[sectionId]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isOpen ? "33.333333%" : "48px",
|
||||
right: 0,
|
||||
}}
|
||||
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
||||
{/* 收起时显示的展开按钮 */}
|
||||
{!isOpen && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onToggle}
|
||||
className="h-full w-12 flex items-center justify-center hover:bg-gray-100">
|
||||
<BookOpenIcon className="w-6 h-6 text-gray-600" />
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* 展开的课程大纲 */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="h-full flex flex-col">
|
||||
{/* 标题栏 */}
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">课程大纲</h2>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<XMarkIcon className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 课程大纲内容 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{/* 原有的 sections mapping 内容 */}
|
||||
{sections.map((section) => (
|
||||
<motion.div
|
||||
key={section.id}
|
||||
ref={(el) =>
|
||||
(sectionRefs.current[section.id] =
|
||||
el)
|
||||
}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
onClick={() =>
|
||||
toggleSection(section.id)
|
||||
}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-medium text-gray-700">
|
||||
第
|
||||
{Math.floor(section.order)}
|
||||
章
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-left font-medium text-gray-900">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{section.totalLectures}
|
||||
节课 ·{" "}
|
||||
{Math.floor(
|
||||
section.totalDuration /
|
||||
60
|
||||
)}
|
||||
分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{
|
||||
rotate: expandedSections.includes(
|
||||
section.id
|
||||
)
|
||||
? 180
|
||||
: 0,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500" />
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedSections.includes(
|
||||
section.id
|
||||
) && (
|
||||
<motion.div
|
||||
initial={{
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
height: "auto",
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
}}
|
||||
className="border-t">
|
||||
{section.lectures.map(
|
||||
(lecture) => (
|
||||
<motion.button
|
||||
key={lecture.id}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
x: -20,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors"
|
||||
onClick={() =>
|
||||
onLectureClick?.(
|
||||
lecture.id
|
||||
)
|
||||
}>
|
||||
<PlayCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
<div className="flex-grow">
|
||||
<h4 className="font-medium text-gray-800">
|
||||
{
|
||||
lecture.title
|
||||
}
|
||||
</h4>
|
||||
{lecture.description && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{
|
||||
lecture.description
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<ClockIcon className="w-4 h-4" />
|
||||
<span>
|
||||
{
|
||||
lecture.duration
|
||||
}
|
||||
分钟
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
import { BookOpenIcon } from "@heroicons/react/24/outline";
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
interface CollapsedButtonProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const CollapsedButton: React.FC<CollapsedButtonProps> = ({
|
||||
onToggle,
|
||||
}) => (
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onToggle}
|
||||
className="p-2 bg-white rounded-l-lg shadow-lg hover:bg-gray-100">
|
||||
<BookOpenIcon className="w-6 h-6 text-gray-600" />
|
||||
</motion.button>
|
||||
);
|
|
@ -0,0 +1,105 @@
|
|||
import { XMarkIcon, BookOpenIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
PlayCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import React, { useState, useRef, useContext } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { SectionDto } from "@nice/common";
|
||||
import { SyllabusHeader } from "./SyllabusHeader";
|
||||
import { SectionItem } from "./SectionItem";
|
||||
import { CollapsedButton } from "./CollapsedButton";
|
||||
import { CourseDetailContext } from "../CourseDetailContext";
|
||||
|
||||
interface CourseSyllabusProps {
|
||||
sections: SectionDto[];
|
||||
onLectureClick?: (lectureId: string) => void;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||
sections,
|
||||
onLectureClick,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) => {
|
||||
const { isHeaderVisible } = useContext(CourseDetailContext);
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>([]);
|
||||
const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections((prev) =>
|
||||
prev.includes(sectionId)
|
||||
? prev.filter((id) => id !== sectionId)
|
||||
: [...prev, sectionId]
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
sectionRefs.current[sectionId]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{/* 收起时的悬浮按钮 */}
|
||||
{!isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed top-1/3 right-0 -translate-y-1/2 z-20">
|
||||
<CollapsedButton onToggle={onToggle} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isOpen ? "25%" : "0",
|
||||
right: 0,
|
||||
top: isHeaderVisible ? "64px" : "0",
|
||||
}}
|
||||
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="h-full flex flex-col">
|
||||
<SyllabusHeader onToggle={onToggle} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{sections.map((section) => (
|
||||
<SectionItem
|
||||
key={section.id}
|
||||
ref={(el) =>
|
||||
(sectionRefs.current[
|
||||
section.id
|
||||
] = el)
|
||||
}
|
||||
section={section}
|
||||
isExpanded={expandedSections.includes(
|
||||
section.id
|
||||
)}
|
||||
onToggle={toggleSection}
|
||||
onLectureClick={onLectureClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
// components/CourseSyllabus/LectureItem.tsx
|
||||
|
||||
import { Lecture } from "@nice/common";
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ClockIcon, PlayCircleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface LectureItemProps {
|
||||
lecture: Lecture;
|
||||
onClick: (lectureId: string) => void;
|
||||
}
|
||||
|
||||
export const LectureItem: React.FC<LectureItemProps> = ({
|
||||
lecture,
|
||||
onClick,
|
||||
}) => (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors"
|
||||
onClick={() => onClick(lecture.id)}>
|
||||
<PlayCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
<div className="flex-grow">
|
||||
<h4 className="font-medium text-gray-800">{lecture.title}</h4>
|
||||
{lecture.description && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{lecture.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<ClockIcon className="w-4 h-4" />
|
||||
<span>{lecture.duration}分钟</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
|
@ -0,0 +1,68 @@
|
|||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { SectionDto } from "@nice/common";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React from "react";
|
||||
import { LectureItem } from "./LectureItem";
|
||||
|
||||
// components/CourseSyllabus/SectionItem.tsx
|
||||
interface SectionItemProps {
|
||||
section: SectionDto;
|
||||
isExpanded: boolean;
|
||||
onToggle: (sectionId: string) => void;
|
||||
onLectureClick: (lectureId: string) => void;
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
||||
({ section, isExpanded, onToggle, onLectureClick }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => onToggle(section.id)}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-medium text-gray-700">
|
||||
第{Math.floor(section.order)}章
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-left font-medium text-gray-900">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{section.totalLectures}节课 ·{" "}
|
||||
{Math.floor(section.totalDuration / 60)}分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500" />
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border-t">
|
||||
{section.lectures.map((lecture) => (
|
||||
<LectureItem
|
||||
key={lecture.id}
|
||||
lecture={lecture}
|
||||
onClick={onLectureClick}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
|
@ -0,0 +1,18 @@
|
|||
// components/CourseSyllabus/SyllabusHeader.tsx
|
||||
import React from "react";
|
||||
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
interface SyllabusHeaderProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const SyllabusHeader: React.FC<SyllabusHeaderProps> = ({ onToggle }) => (
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">课程大纲</h2>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<XMarkIcon className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export * from "./CourseSyllabus";
|
|
@ -1,28 +0,0 @@
|
|||
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { CourseFormData, useCourseEditor } from './CourseEditorContext';
|
||||
import { CourseLevel, CourseLevelLabel } from '@nice/common';
|
||||
import { FormInput } from '@web/src/components/presentation/form/FormInput';
|
||||
import { FormSelect } from '@web/src/components/presentation/form/FormSelect';
|
||||
import { FormArrayField } from '@web/src/components/presentation/form/FormArrayField';
|
||||
import { convertToOptions } from '@nice/client';
|
||||
|
||||
export function CourseBasicForm() {
|
||||
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
||||
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormInput maxLength={20} name="title" label="课程标题" placeholder="请输入课程标题" />
|
||||
<FormInput maxLength={10} name="subTitle" label="课程副标题" placeholder="请输入课程副标题" />
|
||||
<FormInput
|
||||
name="description"
|
||||
label="课程描述"
|
||||
type="textarea"
|
||||
placeholder="请输入课程描述"
|
||||
/>
|
||||
<FormSelect name='level' label='难度等级' options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
|
||||
import { CourseBasicForm } from "./CourseBasicForm";
|
||||
import { CourseBasicForm } from "./CourseForms/CourseBasicForm";
|
||||
import { CourseFormProvider } from "./CourseEditorContext";
|
||||
import CourseEditorLayout from "./CourseEditorLayout";
|
||||
|
||||
export default function CourseEditor({ id }: { id?: string }) {
|
||||
return <CourseFormProvider editId={id}>
|
||||
<CourseEditorLayout>
|
||||
<CourseBasicForm></CourseBasicForm>
|
||||
</CourseEditorLayout>
|
||||
</CourseFormProvider>
|
||||
return (
|
||||
<CourseFormProvider editId={id}>
|
||||
<CourseEditorLayout>
|
||||
<CourseBasicForm></CourseBasicForm>
|
||||
</CourseEditorLayout>
|
||||
</CourseFormProvider>
|
||||
);
|
||||
}
|
|
@ -3,48 +3,53 @@ import { useNavigate } from "react-router-dom";
|
|||
import { DEFAULT_NAV_ITEMS } from "./navItems";
|
||||
import CourseEditorHeader from "./CourseEditorHeader";
|
||||
import { motion } from "framer-motion";
|
||||
import { NavItem } from "@nice/client"
|
||||
import { NavItem } from "@nice/client";
|
||||
import CourseEditorSidebar from "./CourseEditorSidebar";
|
||||
interface CourseEditorLayoutProps {
|
||||
children: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
export default function CourseEditorLayout({ children }: CourseEditorLayoutProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [selectedSection, setSelectedSection] = useState<number>(0);
|
||||
const [navItems, setNavItems] = useState<NavItem[]>(DEFAULT_NAV_ITEMS);
|
||||
const navigate = useNavigate();
|
||||
const handleNavigation = (item: NavItem, index: number) => {
|
||||
setSelectedSection(index);
|
||||
navigate(item.path);
|
||||
};
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<CourseEditorHeader />
|
||||
<div className="flex pt-16">
|
||||
<CourseEditorSidebar
|
||||
isHovered={isHovered}
|
||||
setIsHovered={setIsHovered}
|
||||
navItems={navItems}
|
||||
selectedSection={selectedSection}
|
||||
onNavigate={handleNavigation}
|
||||
/>
|
||||
<motion.main
|
||||
animate={{ marginLeft: isHovered ? "16rem" : "5rem" }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 25, mass: 1 }}
|
||||
className="flex-1 p-8"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto bg-white rounded-xl shadow-lg">
|
||||
<header className="p-6 border-b border-gray-100">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{navItems[selectedSection]?.label}
|
||||
</h1>
|
||||
</header>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</motion.main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function CourseEditorLayout({
|
||||
children,
|
||||
}: CourseEditorLayoutProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [selectedSection, setSelectedSection] = useState<number>(0);
|
||||
const [navItems, setNavItems] = useState<NavItem[]>(DEFAULT_NAV_ITEMS);
|
||||
const navigate = useNavigate();
|
||||
const handleNavigation = (item: NavItem, index: number) => {
|
||||
setSelectedSection(index);
|
||||
navigate(item.path);
|
||||
};
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<CourseEditorHeader />
|
||||
<div className="flex pt-16">
|
||||
<CourseEditorSidebar
|
||||
isHovered={isHovered}
|
||||
setIsHovered={setIsHovered}
|
||||
navItems={navItems}
|
||||
selectedSection={selectedSection}
|
||||
onNavigate={handleNavigation}
|
||||
/>
|
||||
<motion.main
|
||||
animate={{ marginLeft: isHovered ? "16rem" : "5rem" }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 200,
|
||||
damping: 25,
|
||||
mass: 1,
|
||||
}}
|
||||
className="flex-1 p-8">
|
||||
<div className="max-w-6xl mx-auto bg-white rounded-xl shadow-lg">
|
||||
<header className="p-6 border-b border-gray-100">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{navItems[selectedSection]?.label}
|
||||
</h1>
|
||||
</header>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</motion.main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||
|
||||
import { CourseFormData, useCourseEditor } from "../CourseEditorContext";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
import { FormInput } from "@web/src/components/presentation/form/FormInput";
|
||||
import { FormSelect } from "@web/src/components/presentation/form/FormSelect";
|
||||
import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField";
|
||||
import { convertToOptions } from "@nice/client";
|
||||
import { FormDynamicInputs } from "@web/src/components/presentation/form/FormDynamicInputs";
|
||||
|
||||
export function CourseBasicForm() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
handleSubmit,
|
||||
} = useFormContext<CourseFormData>();
|
||||
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormInput
|
||||
maxLength={20}
|
||||
name="title"
|
||||
label="课程标题"
|
||||
placeholder="请输入课程标题"
|
||||
/>
|
||||
|
||||
<FormDynamicInputs
|
||||
name="audiences"
|
||||
label="目标"></FormDynamicInputs>
|
||||
<FormInput
|
||||
maxLength={10}
|
||||
name="subTitle"
|
||||
label="课程副标题"
|
||||
placeholder="请输入课程副标题"
|
||||
/>
|
||||
<FormInput
|
||||
name="description"
|
||||
label="课程描述"
|
||||
type="textarea"
|
||||
placeholder="请输入课程描述"
|
||||
/>
|
||||
<FormSelect
|
||||
name="level"
|
||||
label="难度等级"
|
||||
options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||
|
||||
import { CourseFormData, useCourseEditor } from "../CourseEditorContext";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
import { FormInput } from "@web/src/components/presentation/form/FormInput";
|
||||
import { FormSelect } from "@web/src/components/presentation/form/FormSelect";
|
||||
import { FormArrayField } from "@web/src/components/presentation/form/FormArrayField";
|
||||
import { convertToOptions } from "@nice/client";
|
||||
|
||||
export function CourseBasicForm() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
handleSubmit,
|
||||
} = useFormContext<CourseFormData>();
|
||||
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormInput
|
||||
maxLength={20}
|
||||
name="title"
|
||||
label="课程标题"
|
||||
placeholder="请输入课程标题"
|
||||
/>
|
||||
<FormInput
|
||||
maxLength={10}
|
||||
name="subTitle"
|
||||
label="课程副标题"
|
||||
placeholder="请输入课程副标题"
|
||||
/>
|
||||
<FormInput
|
||||
name="description"
|
||||
label="课程描述"
|
||||
type="textarea"
|
||||
placeholder="请输入课程描述"
|
||||
/>
|
||||
<FormSelect
|
||||
name="level"
|
||||
label="难度等级"
|
||||
options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,28 +1,30 @@
|
|||
import { AcademicCapIcon, BookOpenIcon, Cog6ToothIcon, VideoCameraIcon } from '@heroicons/react/24/outline';
|
||||
import { NavItem } from '@nice/client';
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
BookOpenIcon,
|
||||
Cog6ToothIcon,
|
||||
VideoCameraIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { NavItem } from "@nice/client";
|
||||
|
||||
export const DEFAULT_NAV_ITEMS: (NavItem & { isCompleted?: boolean })[] = [
|
||||
{
|
||||
|
||||
label: "课程概述",
|
||||
icon: <BookOpenIcon className="w-5 h-5" />,
|
||||
path: "/manage/overview"
|
||||
},
|
||||
{
|
||||
|
||||
label: "目标学员",
|
||||
icon: <AcademicCapIcon className="w-5 h-5" />,
|
||||
path: "/manage/overview"
|
||||
},
|
||||
{
|
||||
|
||||
label: "课程内容",
|
||||
icon: <VideoCameraIcon className="w-5 h-5" />,
|
||||
path: "/manage/content"
|
||||
},
|
||||
{
|
||||
label: "课程设置",
|
||||
icon: <Cog6ToothIcon className="w-5 h-5" />,
|
||||
path: "/manage/settings"
|
||||
},
|
||||
{
|
||||
label: "课程概述",
|
||||
icon: <BookOpenIcon className="w-5 h-5" />,
|
||||
path: "/manage/overview",
|
||||
},
|
||||
{
|
||||
label: "目标学员",
|
||||
icon: <AcademicCapIcon className="w-5 h-5" />,
|
||||
path: "/manage/target",
|
||||
},
|
||||
{
|
||||
label: "课程内容",
|
||||
icon: <VideoCameraIcon className="w-5 h-5" />,
|
||||
path: "/manage/content",
|
||||
},
|
||||
{
|
||||
label: "课程设置",
|
||||
icon: <Cog6ToothIcon className="w-5 h-5" />,
|
||||
path: "/manage/settings",
|
||||
},
|
||||
];
|
|
@ -0,0 +1,58 @@
|
|||
// components/NavBar.tsx
|
||||
import { motion } from "framer-motion";
|
||||
import React, { useState } from "react";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface NavBarProps {
|
||||
items: NavItem[];
|
||||
defaultSelected?: string;
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const NavBar = ({ items, defaultSelected, onSelect }: NavBarProps) => {
|
||||
const [selected, setSelected] = useState(defaultSelected || items[0]?.id);
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setSelected(id);
|
||||
onSelect?.(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-white px-4 py-2 shadow-sm">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<ul className="flex items-center space-x-8">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="relative">
|
||||
<button
|
||||
onClick={() => handleSelect(item.id)}
|
||||
className={`flex items-center space-x-2 px-2 py-4 text-sm font-medium transition-colors
|
||||
${selected === item.id ? "text-black" : "text-gray-500 hover:text-gray-800"}`}>
|
||||
{item.icon && (
|
||||
<span className="w-4 h-4">{item.icon}</span>
|
||||
)}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{selected === item.id && (
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-black"
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
|
@ -1,89 +1,131 @@
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
import { PlusIcon, XMarkIcon, Bars3Icon } from '@heroicons/react/24/outline';
|
||||
import { Reorder } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import FormError from './FormError';
|
||||
import { UUIDGenerator } from '@nice/common';
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { PlusIcon, XMarkIcon, Bars3Icon } from "@heroicons/react/24/outline";
|
||||
import { Reorder } from "framer-motion";
|
||||
import React, { useState } from "react";
|
||||
import FormError from "./FormError";
|
||||
import { UUIDGenerator } from "@nice/common";
|
||||
interface ArrayFieldProps {
|
||||
name: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
addButtonText?: string;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
name: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
addButtonText?: string;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
}
|
||||
type ItemType = { id: string; value: string };
|
||||
const inputStyles = "w-full rounded-md border border-gray-300 bg-white p-2 outline-none transition-all duration-300 ease-out focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:ring-opacity-50 placeholder:text-gray-400 shadow-sm";
|
||||
const buttonStyles = "rounded-md inline-flex items-center gap-1 px-4 py-2 text-sm font-medium transition-colors ";
|
||||
export function FormArrayField({ name,
|
||||
label,
|
||||
placeholder,
|
||||
addButtonText = "添加项目",
|
||||
inputProps = {} }: ArrayFieldProps) {
|
||||
const { register, watch, setValue, formState: { errors }, trigger } = useFormContext();
|
||||
const [items, setItems] = useState<ItemType[]>(() =>
|
||||
(watch(name) as string[])?.map(value => ({ id: UUIDGenerator.generate(), value })) || []
|
||||
);
|
||||
const error = errors[name]?.message as string;
|
||||
const inputStyles =
|
||||
"w-full rounded-md border border-gray-300 bg-white p-2 outline-none transition-all duration-300 ease-out focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:ring-opacity-50 placeholder:text-gray-400 shadow-sm";
|
||||
const buttonStyles =
|
||||
"rounded-md inline-flex items-center gap-1 px-4 py-2 text-sm font-medium transition-colors ";
|
||||
export function FormArrayField({
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
addButtonText = "添加项目",
|
||||
inputProps = {},
|
||||
}: ArrayFieldProps) {
|
||||
const {
|
||||
register,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
trigger,
|
||||
} = useFormContext();
|
||||
const [items, setItems] = useState<ItemType[]>(
|
||||
() =>
|
||||
(watch(name) as string[])?.map((value) => ({
|
||||
id: UUIDGenerator.generate(),
|
||||
value,
|
||||
})) || []
|
||||
);
|
||||
const error = errors[name]?.message as string;
|
||||
|
||||
const updateItems = (newItems: ItemType[]) => {
|
||||
setItems(newItems);
|
||||
setValue(name, newItems.map(item => item.value));
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
||||
<div className="space-y-3">
|
||||
<Reorder.Group axis="y" values={items} onReorder={updateItems} className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<Reorder.Item
|
||||
key={item.id}
|
||||
value={item}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="group"
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
{...register(`${name}.${index}`)}
|
||||
{...inputProps}
|
||||
value={item.value}
|
||||
onChange={e => updateItems(
|
||||
items.map(i => i.id === item.id ? { ...i, value: e.target.value } : i)
|
||||
)}
|
||||
onBlur={() => trigger(name)}
|
||||
placeholder={placeholder}
|
||||
className={inputStyles}
|
||||
/>
|
||||
{inputProps.maxLength && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-gray-400">
|
||||
{inputProps.maxLength - (item.value?.length || 0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateItems(items.filter(i => i.id !== item.id))}
|
||||
className="absolute -right-10 p-2 text-gray-400 hover:text-red-500 transition-colors rounded-md hover:bg-red-50 focus:ring-red-200 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
const updateItems = (newItems: ItemType[]) => {
|
||||
setItems(newItems);
|
||||
setValue(
|
||||
name,
|
||||
newItems.map((item) => item.value)
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={items}
|
||||
onReorder={updateItems}
|
||||
className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<Reorder.Item
|
||||
key={item.id}
|
||||
value={item}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="group">
|
||||
<div className="relative flex items-center">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
{...register(`${name}.${index}`)}
|
||||
{...inputProps}
|
||||
value={item.value}
|
||||
onChange={(e) =>
|
||||
updateItems(
|
||||
items.map((i) =>
|
||||
i.id === item.id
|
||||
? {
|
||||
...i,
|
||||
value: e.target
|
||||
.value,
|
||||
}
|
||||
: i
|
||||
)
|
||||
)
|
||||
}
|
||||
onBlur={() => trigger(name)}
|
||||
placeholder={placeholder}
|
||||
className={inputStyles}
|
||||
/>
|
||||
{inputProps.maxLength && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-gray-400">
|
||||
{inputProps.maxLength -
|
||||
(item.value?.length || 0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateItems(
|
||||
items.filter(
|
||||
(i) => i.id !== item.id
|
||||
)
|
||||
)
|
||||
}
|
||||
className="absolute -right-10 p-2 text-gray-400 hover:text-red-500 transition-colors rounded-md hover:bg-red-50 focus:ring-red-200 opacity-0 group-hover:opacity-100">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateItems([...items, { id: UUIDGenerator.generate(), value: '' }])}
|
||||
className={`${buttonStyles} text-blue-600 bg-blue-50 hover:bg-blue-100 focus:ring-blue-500`}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{addButtonText}
|
||||
</button>
|
||||
</div>
|
||||
<FormError error={error}></FormError>
|
||||
</div>
|
||||
);
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateItems([
|
||||
...items,
|
||||
{ id: UUIDGenerator.generate(), value: "" },
|
||||
])
|
||||
}
|
||||
className={`${buttonStyles} text-blue-600 bg-blue-50 hover:bg-blue-100 focus:ring-blue-500`}>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{addButtonText}
|
||||
</button>
|
||||
</div>
|
||||
<FormError error={error}></FormError>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
// components/FormDynamicInputs.tsx
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React, { useState } from "react";
|
||||
import { CheckIcon, XMarkIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import FormError from "./FormError";
|
||||
|
||||
export interface DynamicFormInputProps
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"type"
|
||||
> {
|
||||
name: string;
|
||||
label: string;
|
||||
type?:
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "password"
|
||||
| "email"
|
||||
| "number"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "search"
|
||||
| "date"
|
||||
| "time"
|
||||
| "datetime-local";
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export function FormDynamicInputs({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
rows = 4,
|
||||
className,
|
||||
...restProps
|
||||
}: DynamicFormInputProps) {
|
||||
const [focusedIndexes, setFocusedIndexes] = useState<number[]>([]);
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
trigger,
|
||||
control,
|
||||
} = useFormContext();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name,
|
||||
});
|
||||
|
||||
const handleBlur = async (index: number) => {
|
||||
setFocusedIndexes(focusedIndexes.filter((i) => i !== index));
|
||||
await trigger(`${name}.${index}`);
|
||||
};
|
||||
|
||||
const values = watch(name) || [];
|
||||
const fieldErrors = errors[name] as any;
|
||||
|
||||
const inputClasses = `
|
||||
w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm
|
||||
transition-all duration-300 ease-out placeholder:text-gray-400
|
||||
border-gray-300 focus:border-blue-500 focus:ring-blue-200
|
||||
`;
|
||||
|
||||
const InputElement = type === "textarea" ? "textarea" : "input";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{fields.map((field, index) => (
|
||||
<motion.div
|
||||
key={field.id}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="group relative">
|
||||
<div className="relative">
|
||||
<InputElement
|
||||
{...register(`${name}.${index}`)}
|
||||
type={type !== "textarea" ? type : undefined}
|
||||
rows={type === "textarea" ? rows : undefined}
|
||||
{...restProps}
|
||||
onFocus={() =>
|
||||
setFocusedIndexes([
|
||||
...focusedIndexes,
|
||||
index,
|
||||
])
|
||||
}
|
||||
onBlur={() => handleBlur(index)}
|
||||
className={inputClasses}
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center space-x-1">
|
||||
{values[index] &&
|
||||
focusedIndexes.includes(index) && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
onMouseDown={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onClick={() =>
|
||||
setValue(`${name}.${index}`, "")
|
||||
}>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{values[index] && !fieldErrors?.[index] && (
|
||||
<CheckIcon className="text-green-500 w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{index > 0 && (
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => remove(index)}
|
||||
className="absolute -right-2 -top-2 p-1 bg-red-500 rounded-full
|
||||
text-white shadow-sm opacity-0 group-hover:opacity-100
|
||||
transition-opacity duration-200">
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
{fieldErrors?.[index]?.message && (
|
||||
<FormError error={fieldErrors[index].message} />
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => append("")}
|
||||
className="flex items-center gap-1 text-blue-500 hover:text-blue-600
|
||||
transition-colors px-4 py-2 rounded-lg hover:bg-blue-50">
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
添加新{label}
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,88 +1,105 @@
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import FormError from './FormError';
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React, { useState } from "react";
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import FormError from "./FormError";
|
||||
|
||||
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local';
|
||||
rows?: number;
|
||||
export interface FormInputProps
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"type"
|
||||
> {
|
||||
name: string;
|
||||
label: string;
|
||||
type?:
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "password"
|
||||
| "email"
|
||||
| "number"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "search"
|
||||
| "date"
|
||||
| "time"
|
||||
| "datetime-local";
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export function FormInput({
|
||||
name,
|
||||
label,
|
||||
type = 'text',
|
||||
rows = 4,
|
||||
className,
|
||||
...restProps
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
rows = 4,
|
||||
className,
|
||||
...restProps
|
||||
}: FormInputProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
trigger, // Add trigger from useFormContext
|
||||
} = useFormContext();
|
||||
const handleBlur = async () => {
|
||||
setIsFocused(false);
|
||||
await trigger(name); // Trigger validation for this field
|
||||
};
|
||||
const value = watch(name);
|
||||
const error = errors[name]?.message as string;
|
||||
const isValid = value && !error;
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
trigger, // Add trigger from useFormContext
|
||||
} = useFormContext();
|
||||
const handleBlur = async () => {
|
||||
setIsFocused(false);
|
||||
await trigger(name); // Trigger validation for this field
|
||||
};
|
||||
const value = watch(name);
|
||||
const error = errors[name]?.message as string;
|
||||
const isValid = value && !error;
|
||||
|
||||
const inputClasses = `
|
||||
const inputClasses = `
|
||||
w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm
|
||||
transition-all duration-300 ease-out placeholder:text-gray-400
|
||||
${error ? 'border-red-500 focus:border-red-500 focus:ring-red-200' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-200'}
|
||||
${isFocused ? 'ring-2 ring-opacity-50' : ''}
|
||||
${className || ''}
|
||||
${error ? "border-red-500 focus:border-red-500 focus:ring-red-200" : "border-gray-300 focus:border-blue-500 focus:ring-blue-200"}
|
||||
${isFocused ? "ring-2 ring-opacity-50" : ""}
|
||||
${className || ""}
|
||||
`;
|
||||
|
||||
const InputElement = type === 'textarea' ? 'textarea' : 'input';
|
||||
const InputElement = type === "textarea" ? "textarea" : "input";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
||||
{restProps.maxLength && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{value?.length || 0}/{restProps.maxLength}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
{restProps.maxLength && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{value?.length || 0}/{restProps.maxLength}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<InputElement
|
||||
{...register(name)}
|
||||
type={type !== 'textarea' ? type : undefined}
|
||||
rows={type === 'textarea' ? rows : undefined}
|
||||
{...restProps}
|
||||
<div className="relative">
|
||||
<InputElement
|
||||
{...register(name)}
|
||||
type={type !== "textarea" ? type : undefined}
|
||||
rows={type === "textarea" ? rows : undefined}
|
||||
{...restProps}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
className={inputClasses}
|
||||
/>
|
||||
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
className={inputClasses}
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center space-x-1">
|
||||
{value && isFocused && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setValue(name, '')}
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{isValid && <CheckIcon className="text-green-500 w-4 h-4" />}
|
||||
</div>
|
||||
<FormError error={error}></FormError>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center space-x-1">
|
||||
{value && isFocused && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setValue(name, "")}>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{isValid && (
|
||||
<CheckIcon className="text-green-500 w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<FormError error={error}></FormError>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import { SunIcon } from "@heroicons/react/24/solid";
|
||||
import { useContext } from "react";
|
||||
import { VideoPlayerContext } from "../VideoPlayer";
|
||||
|
||||
export default function Brightness() {
|
||||
const { brightness, setBrightness } = useContext(VideoPlayerContext);
|
||||
return (
|
||||
<>
|
||||
{/* 亮度控制 */}
|
||||
<div className="relative group flex items-center">
|
||||
<button className="text-white hover:text-primaryHover">
|
||||
<SunIcon className="w-10 h-10" />
|
||||
</button>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="bg-black/80 rounded-lg p-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={brightness}
|
||||
onChange={(e) =>
|
||||
setBrightness(parseFloat(e.target.value))
|
||||
}
|
||||
className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { useContext } from "react";
|
||||
import { VideoPlayerContext } from "../VideoPlayer";
|
||||
import {
|
||||
ArrowsPointingInIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
export default function FullScreen() {
|
||||
const { videoRef } = useContext(VideoPlayerContext);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
videoRef.current?.parentElement?.requestFullscreen();
|
||||
}
|
||||
}}
|
||||
className="text-white hover:text-primary-400">
|
||||
{document.fullscreenElement ? (
|
||||
<ArrowsPointingInIcon className="w-10 h-10" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="w-10 h-10" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { useContext } from "react";
|
||||
import { VideoPlayerContext } from "../VideoPlayer";
|
||||
import { PauseIcon, PlayIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export default function Play() {
|
||||
const { isPlaying, videoRef } = useContext(VideoPlayerContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
videoRef.current?.paused
|
||||
? videoRef.current.play()
|
||||
: videoRef.current?.pause()
|
||||
}
|
||||
className="text-white hover:text-primaryHover">
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-10 h-10" />
|
||||
) : (
|
||||
<PlayIcon className="w-10 h-10" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { useContext } from "react";
|
||||
import { VideoPlayerContext } from "../VideoPlayer";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Cog6ToothIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export default function Setting() {
|
||||
const {
|
||||
isSettingsOpen,
|
||||
setIsSettingsOpen,
|
||||
resolution,
|
||||
setResolution,
|
||||
resolutions,
|
||||
} = useContext(VideoPlayerContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
||||
className="text-white hover:text-primaryHover">
|
||||
<Cog6ToothIcon className="w-10 h-10" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isSettingsOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="absolute right-0 bottom-full mb-2 bg-black/90 rounded-lg p-4 min-w-[200px]">
|
||||
{/* 清晰度选择器 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-white text-sm mb-2">
|
||||
清晰度
|
||||
</div>
|
||||
{resolutions.map((res) => (
|
||||
<button
|
||||
key={res.id}
|
||||
onClick={() => {
|
||||
setResolution(res.id);
|
||||
setIsSettingsOpen(false); // 选择后关闭菜单
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded
|
||||
${
|
||||
resolution === res.id
|
||||
? "bg-primary text-white"
|
||||
: "text-white/90 hover:bg-white/20"
|
||||
}
|
||||
transition-colors duration-200
|
||||
`}>
|
||||
{res.label || `${res.height}p`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import { useContext } from "react";
|
||||
import { VideoPlayerContext } from "../VideoPlayer";
|
||||
import { ChevronUpDownIcon } from "@heroicons/react/24/solid";
|
||||
import { PlaybackSpeed } from "../type";
|
||||
|
||||
export default function Speed() {
|
||||
const {
|
||||
setIsSpeedOpen,
|
||||
isSpeedOpen,
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
videoRef,
|
||||
} = useContext(VideoPlayerContext);
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
onClick={() => setIsSpeedOpen(!isSpeedOpen)}
|
||||
className="text-white hover:text-primaryHover flex items-center">
|
||||
<span className="text-xl font-bold mr-1">
|
||||
{playbackSpeed === 1.0 ? "倍速" : `${playbackSpeed}x`}
|
||||
</span>
|
||||
<ChevronUpDownIcon className="w-10 h-10" />
|
||||
</button>
|
||||
{isSpeedOpen && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2">
|
||||
<div className="bg-black/80 rounded-lg p-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
{[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map(
|
||||
(speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={() => {
|
||||
setPlaybackSpeed(
|
||||
speed as PlaybackSpeed
|
||||
);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate =
|
||||
speed;
|
||||
}
|
||||
setIsSpeedOpen(false);
|
||||
}}
|
||||
className={`px-2 py-1 text-lg whitespace-nowrap ${
|
||||
playbackSpeed === speed
|
||||
? "text-primaryHover font-bold"
|
||||
: "text-white hover:text-primaryHover"
|
||||
}`}>
|
||||
{speed}x
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import React, { useContext } from "react";
|
||||
import { VideoPlayerContext } from "../VideoPlayer";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function TimeLine() {
|
||||
const {
|
||||
currentTime,
|
||||
duration,
|
||||
progressRef,
|
||||
setIsDragging,
|
||||
videoRef,
|
||||
isDragging,
|
||||
isHovering,
|
||||
} = useContext(VideoPlayerContext);
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!videoRef.current || !progressRef.current) return;
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
videoRef.current.currentTime = percent * videoRef.current.duration;
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="relative h-1 mb-4 cursor-pointer group"
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={(e) => {
|
||||
setIsDragging(true);
|
||||
handleProgressClick(e);
|
||||
}}>
|
||||
{/* 背景条 */}
|
||||
<div className="absolute w-full h-full bg-gray-500/70 rounded-full" />
|
||||
{/* 播放进度 */}
|
||||
<motion.div
|
||||
className="absolute h-full bg-primary rounded-full"
|
||||
style={{
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
}}
|
||||
transition={{ type: "tween" }}
|
||||
/>
|
||||
{/* 进度球 */}
|
||||
<motion.div
|
||||
className={`z-20 absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-primaryHover border-primaryActive border shadow-lg
|
||||
${isHovering || isDragging ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
|
||||
style={{
|
||||
left: `${(currentTime / duration) * 100}%`,
|
||||
}}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
{/* 预览进度 */}
|
||||
<motion.div
|
||||
className="z-10 absolute h-full bg-primary rounded-full opacity-0 group-hover:opacity-100 pointer-events-none"
|
||||
style={{
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
transform: "scaleY(2.5)",
|
||||
transformOrigin: "center",
|
||||
}}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { useContext } from "react";
|
||||
import { VideoPlayerContext } from "../VideoPlayer";
|
||||
import { SpeakerWaveIcon, SpeakerXMarkIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export default function Volume() {
|
||||
const { isMuted, setIsMuted, volume, setVolume, videoRef } =
|
||||
useContext(VideoPlayerContext);
|
||||
return (
|
||||
<>
|
||||
{/* 音量控制 */}
|
||||
<div className="group relative flex items-center">
|
||||
<button
|
||||
onClick={() => setIsMuted(!isMuted)}
|
||||
className="text-white hover:text-primaryHover">
|
||||
{isMuted ? (
|
||||
<SpeakerXMarkIcon className="w-10 h-10" />
|
||||
) : (
|
||||
<SpeakerWaveIcon className="w-10 h-10" />
|
||||
)}
|
||||
</button>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="bg-black/80 rounded-lg p-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
setVolume(newVolume);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = newVolume;
|
||||
}
|
||||
}}
|
||||
className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./Brightness";
|
||||
export * from "./Volume";
|
||||
export * from "./Speed";
|
||||
export * from "./Play";
|
|
@ -2,7 +2,7 @@ import React, { useContext, useEffect, useRef, useState } from "react";
|
|||
import Hls from "hls.js";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
import "plyr/dist/plyr.css";
|
||||
|
||||
import { VideoPlayerContext } from "./VideoPlayer";
|
||||
export const LoadingOverlay = () => {
|
||||
const { loadingProgress } = useContext(VideoPlayerContext);
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useEffect, useRef, useContext, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
PlayIcon,
|
||||
|
@ -9,11 +8,20 @@ import {
|
|||
Cog6ToothIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
ArrowsPointingInIcon,
|
||||
ChevronUpDownIcon,
|
||||
SunIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import "plyr/dist/plyr.css";
|
||||
|
||||
import { VideoPlayerContext } from "./VideoPlayer";
|
||||
import { formatTime } from "./utlis";
|
||||
import { PlaybackSpeed } from "./type";
|
||||
import Volume from "./ControlButtons/Volume";
|
||||
import Brightness from "./ControlButtons/Brightness";
|
||||
import Speed from "./ControlButtons/Speed";
|
||||
import Play from "./ControlButtons/Play";
|
||||
import Setting from "./ControlButtons/Setting";
|
||||
import FullScreen from "./ControlButtons/FullScreen";
|
||||
import TimeLine from "./ControlButtons/TimeLine";
|
||||
|
||||
export const Controls = () => {
|
||||
const {
|
||||
|
@ -27,6 +35,8 @@ export const Controls = () => {
|
|||
isReady,
|
||||
setIsReady,
|
||||
isPlaying,
|
||||
setIsSpeedOpen,
|
||||
isSpeedOpen,
|
||||
setIsPlaying,
|
||||
|
||||
bufferingState,
|
||||
|
@ -46,16 +56,12 @@ export const Controls = () => {
|
|||
isDragging,
|
||||
setIsDragging,
|
||||
isHovering,
|
||||
isBrightnessOpen,
|
||||
setIsBrightnessOpen,
|
||||
setIsHovering,
|
||||
progressRef,
|
||||
} = useContext(VideoPlayerContext);
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!videoRef.current || !progressRef.current) return;
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
videoRef.current.currentTime = percent * videoRef.current.duration;
|
||||
};
|
||||
// 控制栏显示逻辑
|
||||
useEffect(() => {
|
||||
let timer: number;
|
||||
|
@ -84,190 +90,35 @@ export const Controls = () => {
|
|||
}}
|
||||
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="relative h-1 mb-4 cursor-pointer group"
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={(e) => {
|
||||
setIsDragging(true);
|
||||
handleProgressClick(e);
|
||||
}}>
|
||||
{/* 背景条 */}
|
||||
<div className="absolute w-full h-full bg-black/80 rounded-full" />
|
||||
{/* 播放进度 */}
|
||||
<motion.div
|
||||
className="absolute h-full bg-primary-500 rounded-full"
|
||||
style={{
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
}}
|
||||
transition={{ type: "tween" }}
|
||||
/>
|
||||
{/* 进度球 */}
|
||||
<motion.div
|
||||
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-primary shadow-lg
|
||||
${isHovering || isDragging ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
|
||||
style={{
|
||||
left: `${(currentTime / duration) * 100}%`,
|
||||
}}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
{/* 预览进度 */}
|
||||
<motion.div
|
||||
className="absolute h-full bg-white/30 rounded-full opacity-0 group-hover:opacity-100 pointer-events-none"
|
||||
style={{
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
transform: "scaleY(2.5)",
|
||||
transformOrigin: "center",
|
||||
}}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
<TimeLine></TimeLine>
|
||||
|
||||
{/* 控制按钮区域 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button
|
||||
onClick={() =>
|
||||
videoRef.current?.paused
|
||||
? videoRef.current.play()
|
||||
: videoRef.current?.pause()
|
||||
}
|
||||
className="text-white hover:text-primary-400">
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<PlayIcon className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 音量控制 */}
|
||||
<div className="group relative flex items-center">
|
||||
<button
|
||||
onClick={() => setIsMuted(!isMuted)}
|
||||
className="text-white hover:text-primary-400">
|
||||
{isMuted ? (
|
||||
<SpeakerXMarkIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<SpeakerWaveIcon className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="bg-black/80 rounded-lg p-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const newVolume = parseFloat(
|
||||
e.target.value
|
||||
);
|
||||
setVolume(newVolume);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = newVolume;
|
||||
}
|
||||
}}
|
||||
className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Play></Play>
|
||||
|
||||
{/* 时间显示 */}
|
||||
{duration && (
|
||||
<span className="text-white text-sm">
|
||||
<span className="text-white text-xl">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 右侧控制按钮 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 设置按钮 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
||||
className="text-white hover:text-primary-400">
|
||||
<Cog6ToothIcon className="w-6 h-6" />
|
||||
</button>
|
||||
{/* 设置菜单 */}
|
||||
<AnimatePresence>
|
||||
{isSettingsOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="absolute right-0 bottom-full mb-2 bg-black/90 rounded-lg p-4 min-w-[200px]">
|
||||
{/* 倍速选择 */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-white text-sm mb-2">
|
||||
播放速度
|
||||
</h3>
|
||||
{[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map(
|
||||
(speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={() => {
|
||||
setPlaybackSpeed(
|
||||
speed as PlaybackSpeed
|
||||
);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate =
|
||||
speed;
|
||||
}
|
||||
}}
|
||||
className={`block w-full text-left px-2 py-1 text-sm ${
|
||||
playbackSpeed === speed
|
||||
? "text-primaryHover"
|
||||
: "text-white"
|
||||
}`}>
|
||||
{speed}x
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{/* 音量 */}
|
||||
<Volume></Volume>
|
||||
{/* 亮度 */}
|
||||
<Brightness></Brightness>
|
||||
|
||||
{/* 亮度调节 */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-white text-sm mb-2">
|
||||
亮度
|
||||
</h3>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={brightness}
|
||||
onChange={(e) =>
|
||||
setBrightness(
|
||||
parseFloat(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full accent-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{/* 倍速控制 */}
|
||||
<Speed></Speed>
|
||||
{/* 设置按钮 */}
|
||||
<Setting></Setting>
|
||||
|
||||
{/* 全屏按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
videoRef.current?.parentElement?.requestFullscreen();
|
||||
}
|
||||
}}
|
||||
className="text-white hover:text-primary-400">
|
||||
{document.fullscreenElement ? (
|
||||
<ArrowsPointingInIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
<FullScreen></FullScreen>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
@ -1,61 +1,38 @@
|
|||
// VideoPlayer.tsx
|
||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import "plyr/dist/plyr.css";
|
||||
|
||||
import { VideoPlayerContext } from "./VideoPlayer";
|
||||
|
||||
interface VideoScreenProps {
|
||||
interface VideoDisplayProps {
|
||||
autoPlay?: boolean;
|
||||
// className?: string;
|
||||
// qualities?: { label: string; value: string }[];
|
||||
// onQualityChange?: (quality: string) => void;
|
||||
}
|
||||
export const VideoScreen: React.FC<VideoScreenProps> = ({
|
||||
export const VideoDisplay: React.FC<VideoDisplayProps> = ({
|
||||
autoPlay = false,
|
||||
}) => {
|
||||
const {
|
||||
src,
|
||||
poster,
|
||||
onError,
|
||||
showControls,
|
||||
setShowControls,
|
||||
isSettingsOpen,
|
||||
setIsSettingsOpen,
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
videoRef,
|
||||
isReady,
|
||||
setIsReady,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
error,
|
||||
setError,
|
||||
bufferingState,
|
||||
setBufferingState,
|
||||
volume,
|
||||
setVolume,
|
||||
isMuted,
|
||||
setIsMuted,
|
||||
loadingProgress,
|
||||
setLoadingProgress,
|
||||
currentTime,
|
||||
setCurrentTime,
|
||||
duration,
|
||||
setDuration,
|
||||
brightness,
|
||||
setBrightness,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
isHovering,
|
||||
setIsHovering,
|
||||
progressRef,
|
||||
resolution,
|
||||
setResolutions,
|
||||
} = useContext(VideoPlayerContext);
|
||||
// 处理进度条拖拽
|
||||
|
||||
// 处理进度条拖拽
|
||||
const handleProgressDrag = (e: MouseEvent) => {
|
||||
if (!isDragging || !videoRef.current || !progressRef.current) return;
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const percent = Math.max(
|
||||
0,
|
||||
|
@ -68,23 +45,19 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
|||
useEffect(() => {
|
||||
const handleMouseUp = () => setIsDragging(false);
|
||||
const handleMouseMove = (e: MouseEvent) => handleProgressDrag(e);
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
// 添加控制栏组件
|
||||
|
||||
// 初始化 HLS 和事件监听
|
||||
useEffect(() => {
|
||||
let hls: Hls;
|
||||
|
||||
const initializeHls = async () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
|
@ -98,6 +71,10 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
|||
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
videoRef.current.src = src;
|
||||
setIsReady(true);
|
||||
|
||||
// 设置视频时长
|
||||
setDuration(videoRef.current.duration);
|
||||
|
||||
if (autoPlay) {
|
||||
try {
|
||||
await videoRef.current.play();
|
||||
|
@ -128,6 +105,10 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
|||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, async () => {
|
||||
setIsReady(true);
|
||||
|
||||
// 设置视频时长
|
||||
setDuration(videoRef.current?.duration || 0);
|
||||
|
||||
if (autoPlay && videoRef.current) {
|
||||
try {
|
||||
await videoRef.current.play();
|
||||
|
@ -141,7 +122,6 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
|||
hls.on(Hls.Events.BUFFER_APPENDING, () => {
|
||||
setBufferingState(true);
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.FRAG_BUFFERED, (_, data) => {
|
||||
setBufferingState(false);
|
||||
if (data.stats) {
|
||||
|
@ -150,8 +130,8 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
|||
setLoadingProgress(Math.round(progress));
|
||||
}
|
||||
});
|
||||
let networkError;
|
||||
let fatalError;
|
||||
let networkError;
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
|
@ -178,20 +158,34 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
|||
}
|
||||
});
|
||||
};
|
||||
// Event handlers
|
||||
|
||||
// 事件处理
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
const handleEnded = () => setIsPlaying(false);
|
||||
const handleWaiting = () => setBufferingState(true);
|
||||
const handlePlaying = () => setBufferingState(false);
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
// 设置视频时长
|
||||
setDuration(videoRef.current.duration);
|
||||
}
|
||||
};
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.addEventListener("play", handlePlay);
|
||||
videoRef.current.addEventListener("pause", handlePause);
|
||||
videoRef.current.addEventListener("ended", handleEnded);
|
||||
videoRef.current.addEventListener("waiting", handleWaiting);
|
||||
videoRef.current.addEventListener("playing", handlePlaying);
|
||||
videoRef.current.addEventListener(
|
||||
"loadedmetadata",
|
||||
handleLoadedMetadata
|
||||
);
|
||||
}
|
||||
|
||||
initializeHls();
|
||||
|
||||
return () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.removeEventListener("play", handlePlay);
|
||||
|
@ -199,6 +193,10 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
|||
videoRef.current.removeEventListener("ended", handleEnded);
|
||||
videoRef.current.removeEventListener("waiting", handleWaiting);
|
||||
videoRef.current.removeEventListener("playing", handlePlaying);
|
||||
videoRef.current.removeEventListener(
|
||||
"loadedmetadata",
|
||||
handleLoadedMetadata
|
||||
);
|
||||
}
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
|
@ -218,7 +216,6 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
|||
onTimeUpdate={() => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
setDuration(videoRef.current.duration);
|
||||
}
|
||||
}}
|
||||
/>
|
|
@ -1,6 +1,7 @@
|
|||
import React, { createContext, ReactNode, useRef, useState } from "react";
|
||||
import { PlaybackSpeed } from "./type";
|
||||
import VideoPlayerLayout from "./VideoPlayerLayout";
|
||||
import { Resolution } from "./interface";
|
||||
|
||||
interface VideoPlayerContextType {
|
||||
src: string;
|
||||
|
@ -38,6 +39,14 @@ interface VideoPlayerContextType {
|
|||
isHovering: boolean;
|
||||
setIsHovering: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
progressRef: React.RefObject<HTMLDivElement>;
|
||||
isSpeedOpen: boolean;
|
||||
setIsSpeedOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isBrightnessOpen: boolean;
|
||||
setIsBrightnessOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
resolution: number;
|
||||
setResolution: React.Dispatch<React.SetStateAction<number>>;
|
||||
resolutions: Resolution[];
|
||||
setResolutions: React.Dispatch<React.SetStateAction<Resolution[]>>;
|
||||
}
|
||||
export const VideoPlayerContext = createContext<VideoPlayerContextType | null>(
|
||||
null
|
||||
|
@ -66,10 +75,13 @@ export function VideoPlayer({
|
|||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [brightness, setBrightness] = useState(1);
|
||||
const [resolution, setResolution] = useState(-1);
|
||||
const [resolutions, setResolutions] = useState<Resolution[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isSpeedOpen, setIsSpeedOpen] = useState(false);
|
||||
const [isBrightnessOpen, setIsBrightnessOpen] = useState(false);
|
||||
return (
|
||||
<VideoPlayerContext.Provider
|
||||
value={{
|
||||
|
@ -108,6 +120,14 @@ export function VideoPlayer({
|
|||
isHovering,
|
||||
setIsHovering,
|
||||
progressRef,
|
||||
isSpeedOpen,
|
||||
setIsSpeedOpen,
|
||||
isBrightnessOpen,
|
||||
setIsBrightnessOpen,
|
||||
resolution,
|
||||
setResolution,
|
||||
resolutions,
|
||||
setResolutions,
|
||||
}}>
|
||||
<VideoPlayerLayout></VideoPlayerLayout>
|
||||
</VideoPlayerContext.Provider>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useContext } from "react";
|
|||
import { VideoPlayerContext } from "./VideoPlayer";
|
||||
import Controls from "./VideoControls";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { VideoScreen } from "./VideoScreen";
|
||||
import { VideoDisplay } from "./VideoDisplay";
|
||||
import LoadingOverlay from "./LoadingOverlay";
|
||||
|
||||
export default function VideoPlayerLayout() {
|
||||
|
@ -17,14 +17,15 @@ export default function VideoPlayerLayout() {
|
|||
return (
|
||||
<>
|
||||
<div
|
||||
className={`relative aspect-video w-full bg-black rounded-lg overflow-hidden`}
|
||||
className={`relative w-full bg-black rounded-lg overflow-hidden`}
|
||||
style={{ aspectRatio: "21/9" }}
|
||||
onMouseEnter={() => {
|
||||
setIsHovering(true);
|
||||
setShowControls(true);
|
||||
}}>
|
||||
{!isReady && <div>123</div>}
|
||||
{!isReady && <LoadingOverlay></LoadingOverlay>}
|
||||
<VideoScreen></VideoScreen>
|
||||
<VideoDisplay></VideoDisplay>
|
||||
<AnimatePresence>
|
||||
{(showControls || isDragging) && <Controls />}
|
||||
</AnimatePresence>
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
// 定义清晰度选项的类型
|
||||
export interface Resolution {
|
||||
id: number;
|
||||
height: number;
|
||||
width: number;
|
||||
bitrate: number;
|
||||
label: string;
|
||||
url?: string; // 可选:清晰度对应的视频URL
|
||||
active?: boolean; // 可选:是否是当前激活的清晰度
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
export const formatTime = (seconds: number): string => {
|
||||
export const formatTime = (seconds: number = 0): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
|
|
|
@ -107,37 +107,3 @@
|
|||
.custom-table .ant-table-tbody > tr:last-child > td {
|
||||
border-bottom: none; /* 去除最后一行的底部边框 */
|
||||
}
|
||||
/* 自定义 Plyr 样式 */
|
||||
.plyr--full-ui input[type="range"] {
|
||||
color: #ff0000; /* YouTube 红色 */
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: rgba(255, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.plyr--video .plyr__control:hover {
|
||||
background: #ff0000;
|
||||
}
|
||||
|
||||
.plyr--full-ui input[type="range"]::-webkit-slider-thumb {
|
||||
background: #ff0000;
|
||||
}
|
||||
|
||||
.plyr--full-ui input[type="range"]::-moz-range-thumb {
|
||||
background: #ff0000;
|
||||
}
|
||||
|
||||
.plyr--full-ui input[type="range"]::-ms-thumb {
|
||||
background: #ff0000;
|
||||
}
|
||||
|
||||
/* 缓冲条样式 */
|
||||
.plyr__progress__buffer {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* 控制栏背景 */
|
||||
.plyr--video .plyr__controls {
|
||||
background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.7));
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import StudentCoursesPage from "../app/main/courses/student/page";
|
|||
import InstructorCoursesPage from "../app/main/courses/instructor/page";
|
||||
import HomePage from "../app/main/home/page";
|
||||
import { CourseDetailPage } from "../app/main/course/detail/page";
|
||||
|
||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||
name?: string;
|
||||
breadcrumb?: string;
|
||||
|
@ -85,6 +86,20 @@ export const routes: CustomRouteObject[] = [
|
|||
{
|
||||
path: ":id?/manage", // 使用 ? 表示 id 参数是可选的
|
||||
element: <CourseEditorPage />,
|
||||
children: [
|
||||
{
|
||||
index: true, // This will make :id?/manage the default route
|
||||
element: <CourseEditorPage />,
|
||||
},
|
||||
{
|
||||
path: "overview",
|
||||
element: <CourseEditorPage />, // You might want to create a specific overview component
|
||||
},
|
||||
{
|
||||
path: "target",
|
||||
element: <CourseEditorPage />, // Create a specific target page component
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: ":id?/detail", // 使用 ? 表示 id 参数是可选的
|
||||
|
|
|
@ -341,9 +341,6 @@ importers:
|
|||
mitt:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
plyr-react:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0(plyr@3.7.8)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
|
@ -3601,9 +3598,6 @@ packages:
|
|||
copy-to-clipboard@3.3.3:
|
||||
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
|
||||
|
||||
core-js@3.39.0:
|
||||
resolution: {integrity: sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
|
@ -3666,9 +3660,6 @@ packages:
|
|||
custom-error-instance@2.1.1:
|
||||
resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==}
|
||||
|
||||
custom-event-polyfill@1.0.7:
|
||||
resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==}
|
||||
|
||||
date-fns@2.30.0:
|
||||
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
|
||||
engines: {node: '>=0.11'}
|
||||
|
@ -4863,9 +4854,6 @@ packages:
|
|||
resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==}
|
||||
engines: {node: '>=6.11.5'}
|
||||
|
||||
loadjs@4.3.0:
|
||||
resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==}
|
||||
|
||||
locate-path@3.0.0:
|
||||
resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -5379,19 +5367,6 @@ packages:
|
|||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
plyr-react@5.3.0:
|
||||
resolution: {integrity: sha512-m36/HrpHwg1N2rq3E31E8/kpAH55vk6qHUg17MG4uu9jbWYxnkN39lLmZQwxW7/qpDPfW5aGUJ6R3u23V0R3zA==}
|
||||
engines: {node: '>=16'}
|
||||
peerDependencies:
|
||||
plyr: ^3.7.7
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
plyr@3.7.8:
|
||||
resolution: {integrity: sha512-yG/EHDobwbB/uP+4Bm6eUpJ93f8xxHjjk2dYcD1Oqpe1EcuQl5tzzw9Oq+uVAzd2lkM11qZfydSiyIpiB8pgdA==}
|
||||
|
||||
possible-typed-array-names@1.0.0:
|
||||
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
@ -5535,9 +5510,6 @@ packages:
|
|||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
rangetouch@2.0.1:
|
||||
resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==}
|
||||
|
||||
raw-body@2.5.2:
|
||||
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
@ -5770,15 +5742,6 @@ packages:
|
|||
react: '>=16.9.0'
|
||||
react-dom: '>=16.9.0'
|
||||
|
||||
react-aptor@2.0.0:
|
||||
resolution: {integrity: sha512-YnCayokuhAwmBBP4Oc0bbT2l6ApfsjbY3DEEVUddIKZEBlGl1npzjHHzWnSqWuboSbMZvRqUM01Io9yiIp1wcg==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
react-beautiful-dnd@13.1.1:
|
||||
resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==}
|
||||
deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672'
|
||||
|
@ -6587,9 +6550,6 @@ packages:
|
|||
url-parse@1.5.10:
|
||||
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
|
||||
|
||||
url-polyfill@1.1.12:
|
||||
resolution: {integrity: sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A==}
|
||||
|
||||
use-memo-one@1.1.3:
|
||||
resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==}
|
||||
peerDependencies:
|
||||
|
@ -10523,8 +10483,6 @@ snapshots:
|
|||
dependencies:
|
||||
toggle-selection: 1.0.6
|
||||
|
||||
core-js@3.39.0: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.5:
|
||||
|
@ -10605,8 +10563,6 @@ snapshots:
|
|||
|
||||
custom-error-instance@2.1.1: {}
|
||||
|
||||
custom-event-polyfill@1.0.7: {}
|
||||
|
||||
date-fns@2.30.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.25.0
|
||||
|
@ -12098,8 +12054,6 @@ snapshots:
|
|||
|
||||
loader-runner@4.3.0: {}
|
||||
|
||||
loadjs@4.3.0: {}
|
||||
|
||||
locate-path@3.0.0:
|
||||
dependencies:
|
||||
p-locate: 3.0.0
|
||||
|
@ -12546,21 +12500,6 @@ snapshots:
|
|||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
plyr-react@5.3.0(plyr@3.7.8)(react@18.2.0):
|
||||
dependencies:
|
||||
plyr: 3.7.8
|
||||
react-aptor: 2.0.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
react: 18.2.0
|
||||
|
||||
plyr@3.7.8:
|
||||
dependencies:
|
||||
core-js: 3.39.0
|
||||
custom-event-polyfill: 1.0.7
|
||||
loadjs: 4.3.0
|
||||
rangetouch: 2.0.1
|
||||
url-polyfill: 1.1.12
|
||||
|
||||
possible-typed-array-names@1.0.0: {}
|
||||
|
||||
postcss-import@15.1.0(postcss@8.4.49):
|
||||
|
@ -12686,8 +12625,6 @@ snapshots:
|
|||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
rangetouch@2.0.1: {}
|
||||
|
||||
raw-body@2.5.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
|
@ -13016,10 +12953,6 @@ snapshots:
|
|||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
|
||||
react-aptor@2.0.0(react@18.2.0):
|
||||
optionalDependencies:
|
||||
react: 18.2.0
|
||||
|
||||
react-beautiful-dnd@13.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.25.0
|
||||
|
@ -13944,8 +13877,6 @@ snapshots:
|
|||
querystringify: 2.2.0
|
||||
requires-port: 1.0.0
|
||||
|
||||
url-polyfill@1.1.12: {}
|
||||
|
||||
use-memo-one@1.1.3(react@18.2.0):
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
|
|
Loading…
Reference in New Issue