This commit is contained in:
ditiqi 2025-01-08 00:56:15 +08:00
parent eca128de5f
commit 8596b467ff
34 changed files with 1963 additions and 490 deletions

View File

@ -21,5 +21,20 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
// 允许使用 any 类型
'@typescript-eslint/no-explicit-any': 'off',
// 允许声明但未使用的变量
'@typescript-eslint/no-unused-vars': [
'warn',
{
vars: 'all', // 检查所有变量
args: 'none', // 不检查函数参数
ignoreRestSiblings: true,
},
],
// 禁止使用未声明的变量
'no-undef': 'error',
},
};

View File

@ -26,7 +26,7 @@ export class ResourceService extends BaseService<Prisma.ResourceDelegate> {
async checkFileExists(hash: string): Promise<Resource | null> {
return this.findFirst({
where: {
hash,
// hash,
deletedAt: null,
},
});

View File

@ -1,5 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { db, InitAppConfigs, InitRoles, InitTaxonomies, ObjectType } from "@nice/common";
import {
db,
InitAppConfigs,
InitRoles,
InitTaxonomies,
ObjectType,
} from '@nice/common';
import { AuthService } from '@server/auth/auth.service';
import { MinioService } from '@server/utils/minio/minio.service';
import { AppConfigService } from '@server/models/app-config/app-config.service';
@ -7,142 +13,144 @@ import { GenDevService } from './gendev.service';
@Injectable()
export class InitService {
private readonly logger = new Logger(InitService.name);
constructor(
private readonly appConfigService: AppConfigService,
private readonly minioService: MinioService,
private readonly authService: AuthService,
private readonly genDevService: GenDevService
) { }
private async createRoles() {
this.logger.log('Checking existing system roles');
for (const role of InitRoles) {
const existingRole = await db.role.findUnique({
where: { name: role.name },
});
if (!existingRole) {
this.logger.log(`Creating role: ${role.name}`);
await db.role.create({
data: { ...role, system: true },
});
} else {
this.logger.log(`Role already exists: ${role.name}`);
}
}
}
private async createOrUpdateTaxonomy() {
this.logger.log('Checking existing taxonomies');
const existingTaxonomies = await db.taxonomy.findMany();
const existingTaxonomyMap = new Map(existingTaxonomies.map(taxonomy => [taxonomy.name, taxonomy]));
for (const [index, taxonomy] of InitTaxonomies.entries()) {
const existingTaxonomy = existingTaxonomyMap.get(taxonomy.name);
if (!existingTaxonomy) {
// Create new taxonomy
await db.taxonomy.create({
data: {
...taxonomy,
order: index,
},
});
this.logger.log(`Created new taxonomy: ${taxonomy.name}`);
} else {
// Check for differences and update if necessary
const differences = Object.keys(taxonomy).filter(key => taxonomy[key] !== existingTaxonomy[key]);
if (differences.length > 0) {
await db.taxonomy.update({
where: { id: existingTaxonomy.id },
data: {
...taxonomy,
order: index,
},
});
this.logger.log(`Updated taxonomy: ${taxonomy.name}`);
} else {
this.logger.log(`No changes for taxonomy: ${taxonomy.name}`);
}
}
}
}
private async createRoot() {
this.logger.log('Checking for root account');
const rootAccountExists = await db.staff.findFirst({
where: {
OR: [
{
phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000',
},
{
username: 'root',
},
],
},
private readonly logger = new Logger(InitService.name);
constructor(
private readonly appConfigService: AppConfigService,
private readonly minioService: MinioService,
private readonly authService: AuthService,
private readonly genDevService: GenDevService,
) {}
private async createRoles() {
this.logger.log('Checking existing system roles');
for (const role of InitRoles) {
const existingRole = await db.role.findUnique({
where: { name: role.name },
});
if (!existingRole) {
this.logger.log(`Creating role: ${role.name}`);
await db.role.create({
data: { ...role, system: true },
});
} else {
this.logger.log(`Role already exists: ${role.name}`);
}
}
}
private async createOrUpdateTaxonomy() {
this.logger.log('Checking existing taxonomies');
if (!rootAccountExists) {
this.logger.log('Creating root account');
const rootStaff = await this.authService.signUp({
username: 'root',
password: 'root',
});
const rootRole = await db.role.findUnique({
where: { name: '根管理员' },
});
const existingTaxonomies = await db.taxonomy.findMany();
const existingTaxonomyMap = new Map(
existingTaxonomies.map((taxonomy) => [taxonomy.name, taxonomy]),
);
if (rootRole) {
this.logger.log('Assigning root role to root account');
await db.roleMap.create({
data: {
objectType: ObjectType.STAFF,
objectId: rootStaff.id,
roleId: rootRole.id,
},
});
} else {
this.logger.error('Root role does not exist');
}
for (const [index, taxonomy] of InitTaxonomies.entries()) {
const existingTaxonomy = existingTaxonomyMap.get(taxonomy.name);
if (!existingTaxonomy) {
// Create new taxonomy
await db.taxonomy.create({
data: {
...taxonomy,
order: index,
},
});
this.logger.log(`Created new taxonomy: ${taxonomy.name}`);
} else {
// Check for differences and update if necessary
const differences = Object.keys(taxonomy).filter(
(key) => taxonomy[key] !== existingTaxonomy[key],
);
if (differences.length > 0) {
await db.taxonomy.update({
where: { id: existingTaxonomy.id },
data: {
...taxonomy,
order: index,
},
});
this.logger.log(`Updated taxonomy: ${taxonomy.name}`);
} else {
this.logger.log('Root account already exists');
this.logger.log(`No changes for taxonomy: ${taxonomy.name}`);
}
}
}
private async createBucket() {
await this.minioService.createBucket('app')
}
private async initAppConfigs() {
const existingConfigs = await db.appConfig.findMany();
const existingConfigSlugs = existingConfigs.map((config) => config.slug);
for (const [index, config] of InitAppConfigs.entries()) {
if (!existingConfigSlugs.includes(config.slug)) {
this.logger.log(`create Option Page ${config.title}`);
await this.appConfigService.create({ data: config });
} else {
this.logger.log(`AppConfig already exists: ${config.title}`);
}
}
}
async init() {
this.logger.log('Initializing system roles');
await this.createRoles();
this.logger.log('Initializing root account');
await this.createRoot();
this.logger.log('Initializing taxonomies');
await this.createOrUpdateTaxonomy()
this.logger.log('Initialize minio')
await this.createBucket()
this.logger.log('Initializing appConfigs');
await this.initAppConfigs();
if (process.env.NODE_ENV === 'development') {
try {
await this.genDevService.genDataEvent();
} catch (err: any) {
this.logger.error(err.message);
}
}
}
private async createRoot() {
this.logger.log('Checking for root account');
const rootAccountExists = await db.staff.findFirst({
where: {
OR: [
{
phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000',
},
{
username: 'root',
},
],
},
});
if (!rootAccountExists) {
this.logger.log('Creating root account');
const rootStaff = await this.authService.signUp({
username: 'root',
password: 'root',
});
const rootRole = await db.role.findUnique({
where: { name: '根管理员' },
});
if (rootRole) {
this.logger.log('Assigning root role to root account');
await db.roleMap.create({
data: {
objectType: ObjectType.STAFF,
objectId: rootStaff.id,
roleId: rootRole.id,
},
});
} else {
this.logger.error('Root role does not exist');
}
} else {
this.logger.log('Root account already exists');
}
}
private async createBucket() {
await this.minioService.createBucket('app');
}
private async initAppConfigs() {
const existingConfigs = await db.appConfig.findMany();
const existingConfigSlugs = existingConfigs.map((config) => config.slug);
for (const [index, config] of InitAppConfigs.entries()) {
if (!existingConfigSlugs.includes(config.slug)) {
this.logger.log(`create Option Page ${config.title}`);
await this.appConfigService.create({ data: config });
} else {
this.logger.log(`AppConfig already exists: ${config.title}`);
}
}
}
async init() {
this.logger.log('Initializing system roles');
await this.createRoles();
this.logger.log('Initializing root account');
await this.createRoot();
this.logger.log('Initializing taxonomies');
await this.createOrUpdateTaxonomy();
this.logger.log('Initialize minio');
await this.createBucket();
this.logger.log('Initializing appConfigs');
await this.initAppConfigs();
if (process.env.NODE_ENV === 'development') {
try {
await this.genDevService.genDataEvent();
} catch (err: any) {
this.logger.error(err.message);
}
}
}
}

View File

@ -1,28 +1,43 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
// 允许使用 any 类型
"@typescript-eslint/no-explicit-any": "off",
// 允许声明但未使用的变量
"@typescript-eslint/no-unused-vars": [
"warn",
{
vars: "all", // 检查所有变量
args: "none", // 不检查函数参数
ignoreRestSiblings: true,
},
],
// 禁止使用未声明的变量
"no-undef": "error",
},
}
);

View File

@ -46,8 +46,10 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.12",
"framer-motion": "^11.15.0",
"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",
@ -57,9 +59,9 @@
"react-router-dom": "^6.24.1",
"superjson": "^2.2.1",
"tailwind-merge": "^2.6.0",
"uuid": "^10.0.0",
"yjs": "^13.6.20",
"zod": "^3.23.8",
"uuid": "^10.0.0"
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.9.0",

View File

@ -0,0 +1,9 @@
import CourseDetail from "@web/src/components/models/course/detail/CourseDetail";
import CourseEditor from "@web/src/components/models/course/manage/CourseEditor";
import { useParams } from "react-router-dom";
export function CourseDetailPage() {
const { id } = useParams();
console.log("Course ID:", id);
return <CourseDetail id={id}></CourseDetail>;
}

View File

@ -1,34 +1,34 @@
import { CourseCard } from "@web/src/components/models/course/card/CourseCard"
import { CourseDetail } from "@web/src/components/models/course/detail/course-detail"
import { CourseSyllabus } from "@web/src/components/models/course/detail/course-syllabus"
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 */
]
// 假设这些数据从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>
)
}
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>
);
};

View File

@ -8,48 +8,59 @@ import { useNavigate } from "react-router-dom";
import { useAuth } from "@web/src/providers/auth-provider";
export default function InstructorCoursesPage() {
const navigate = useNavigate()
const [currentPage, setCurrentPage] = useState(1);
const { user } = useAuth()
const navigate = useNavigate();
const [currentPage, setCurrentPage] = useState(1);
const { user } = useAuth();
const { data: paginationRes, refetch } = api.course.findManyWithPagination.useQuery({
page: currentPage,
pageSize: 8,
where: {
instructors: {
some: {
instructorId: user?.id
}
}
}
});
const { data: paginationRes, refetch } =
api.course.findManyWithPagination.useQuery({
page: currentPage,
pageSize: 8,
where: {
instructors: {
some: {
instructorId: user?.id,
},
},
},
});
const handlePageChange = (page: number) => {
setCurrentPage(page);
refetch()
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
refetch();
};
return (
<div className="bg-slate-50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<div className="mb-8 flex items-center justify-between">
<h1 className="text-3xl font-semibold text-slate-800"></h1>
<Button
onClick={() => navigate("/course/manage")}
variant="primary"
leftIcon={<PlusIcon className="w-5 h-5" />}
>
</Button>
</div>
<CourseList
totalPages={paginationRes?.totalPages}
onPageChange={handlePageChange}
currentPage={currentPage}
courses={paginationRes?.items as any}
renderItem={(course) => <CourseCard course={course} />}
/>
</div>
</div>
);
}
return (
<div className="bg-slate-50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<div className="mb-8 flex items-center justify-between">
<h1 className="text-3xl font-semibold text-slate-800">
</h1>
<Button
onClick={() => navigate("/course/manage")}
variant="primary"
leftIcon={<PlusIcon className="w-5 h-5" />}>
</Button>
</div>
<CourseList
totalPages={paginationRes?.totalPages}
onPageChange={handlePageChange}
currentPage={currentPage}
courses={paginationRes?.items as any}
renderItem={(course) => (
<CourseCard
onClick={() => {
navigate(`/course/${course.id}/detail`, {
replace: true,
});
}}
course={course}
/>
)}
/>
</div>
</div>
);
}

View File

@ -1,30 +1,32 @@
import { CourseDto } from '@nice/common';
import { Card } from '@web/src/components/presentation/container/Card';
import { CourseHeader } from './CourseHeader';
import { CourseStats } from './CourseStats';
import { CourseDto } from "@nice/common";
import { Card } from "@web/src/components/presentation/container/Card";
import { CourseHeader } from "./CourseHeader";
import { CourseStats } from "./CourseStats";
import { Popover } from "@web/src/components/presentation/popover";
import { useState } from "react";
interface CourseCardProps {
course: CourseDto;
onClick?: () => void;
course: CourseDto;
onClick?: () => void;
}
export const CourseCard = ({ course, onClick }: CourseCardProps) => {
return (
<Card onClick={onClick} className="w-full max-w-sm">
<CourseHeader
title={course.title}
subTitle={course.subTitle}
thumbnail={course.thumbnail}
level={course.level}
numberOfStudents={course.numberOfStudents}
publishedAt={course.publishedAt}
/>
<CourseStats
averageRating={course.averageRating}
numberOfReviews={course.numberOfReviews}
completionRate={course.completionRate}
totalDuration={course.totalDuration}
/>
</Card>
);
};
return (
<Card onClick={onClick} className="w-full max-w-sm">
<CourseHeader
title={course.title}
subTitle={course.subTitle}
thumbnail={course.thumbnail}
level={course.level}
numberOfStudents={course.numberOfStudents}
publishedAt={course.publishedAt}
/>
<CourseStats
averageRating={course.averageRating}
numberOfReviews={course.numberOfReviews}
completionRate={course.completionRate}
totalDuration={course.totalDuration}
/>
</Card>
);
};

View File

@ -1,59 +1,58 @@
import { CalendarIcon, UserGroupIcon, AcademicCapIcon } from '@heroicons/react/24/outline';
import {
CalendarIcon,
UserGroupIcon,
AcademicCapIcon,
} from "@heroicons/react/24/outline";
import { CourseLevelLabel } from "@nice/common";
interface CourseHeaderProps {
title: string;
subTitle?: string;
thumbnail?: string;
level?: string;
numberOfStudents?: number;
publishedAt?: Date;
title: string;
subTitle?: string;
thumbnail?: string;
level?: string;
numberOfStudents?: number;
publishedAt?: Date;
}
export const CourseHeader = ({
title,
subTitle,
thumbnail,
level,
numberOfStudents,
publishedAt,
title,
subTitle,
thumbnail,
level,
numberOfStudents,
publishedAt,
}: CourseHeaderProps) => {
return (
<div className="relative">
{thumbnail && (
<div className="relative h-48 w-full">
<img
src={thumbnail}
alt={title}
className="object-cover"
/>
</div>
)}
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 ">{title}</h3>
{subTitle && (
<p className="mt-2 text-gray-600 ">{subTitle}</p>
)}
<div className="mt-4 flex items-center gap-4 text-sm text-gray-500 ">
{level && (
<div className="flex items-center gap-1">
<AcademicCapIcon className="h-4 w-4" />
<span>{level}</span>
</div>
)}
{numberOfStudents !== undefined && (
<div className="flex items-center gap-1">
<UserGroupIcon className="h-4 w-4" />
<span>{numberOfStudents} students</span>
</div>
)}
{publishedAt && (
<div className="flex items-center gap-1">
<CalendarIcon className="h-4 w-4" />
<span>{publishedAt.toLocaleDateString()}</span>
</div>
)}
</div>
</div>
</div>
);
};
return (
<div className="relative">
{thumbnail && (
<div className="relative h-48 w-full">
<img src={thumbnail} alt={title} className="object-cover" />
</div>
)}
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 ">{title}</h3>
{subTitle && <p className="mt-2 text-gray-600 ">{subTitle}</p>}
<div className="mt-4 flex items-center gap-4 text-sm text-gray-500 ">
{level && (
<div className="flex items-center gap-1">
<AcademicCapIcon className="h-4 w-4" />
<span>{CourseLevelLabel[level]}</span>
</div>
)}
{numberOfStudents !== undefined && (
<div className="flex items-center gap-1">
<UserGroupIcon className="h-4 w-4" />
<span>{numberOfStudents} </span>
</div>
)}
{publishedAt && (
<div className="flex items-center gap-1">
<CalendarIcon className="h-4 w-4" />
<span>{publishedAt.toLocaleDateString()}</span>
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -23,7 +23,7 @@ export const CourseStats = ({
{averageRating.toFixed(1)}
</div>
<div className="text-xs text-gray-500 ">
{numberOfReviews} reviews
{numberOfReviews}
</div>
</div>
</div>
@ -36,7 +36,7 @@ export const CourseStats = ({
{completionRate}%
</div>
<div className="text-xs text-gray-500 ">
Completion
</div>
</div>
</div>
@ -49,7 +49,7 @@ export const CourseStats = ({
{Math.floor(totalDuration / 60)}h {totalDuration % 60}m
</div>
<div className="text-xs text-gray-500 ">
Duration
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
import { CourseDetailProvider } from "./CourseDetailContext";
import CourseDetailLayout from "./CourseDetailLayout";
export default function CourseDetail({ id }: { id?: string }) {
const iframeStyle = {
width: "50%",
height: "100vh",
border: "none",
};
return (
<>
<CourseDetailProvider editId={id}>
<CourseDetailLayout></CourseDetailLayout>
</CourseDetailProvider>
</>
);
}

View File

@ -0,0 +1,84 @@
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>
);
};

View File

@ -0,0 +1,50 @@
import { api, useCourse } from "@nice/client";
import { courseDetailSelect, CourseDto } from "@nice/common";
import React, { createContext, ReactNode, useState } from "react";
import { string } from "zod";
interface CourseDetailContextType {
editId?: string; // 添加 editId
course?: CourseDto;
selectedLectureId?: string | undefined;
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
isLoading?: boolean;
}
interface CourseFormProviderProps {
children: ReactNode;
editId?: string; // 添加 editId 参数
}
export const CourseDetailContext =
createContext<CourseDetailContextType | null>(null);
export function CourseDetailProvider({
children,
editId,
}: CourseFormProviderProps) {
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
api.course.findFirst.useQuery(
{
where: { id: editId },
include: {
sections: { include: { lectures: true } },
enrollments: true,
},
},
{ enabled: Boolean(editId) }
);
const [selectedLectureId, setSelectedLectureId] = useState<
string | undefined
>(undefined);
return (
<CourseDetailContext.Provider
value={{
editId,
course,
selectedLectureId,
setSelectedLectureId,
isLoading,
}}>
{children}
</CourseDetailContext.Provider>
);
}

View File

@ -0,0 +1,42 @@
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";
export default function CourseDetailLayout() {
const { course, selectedLectureId, isLoading, setSelectedLectureId } =
useContext(CourseDetailContext);
const handleLectureClick = (lectureId: string) => {
setSelectedLectureId(lectureId);
};
const [isSyllabusOpen, setIsSyllabusOpen] = useState(false);
return (
<div className="relative">
{/* 主内容区域 */}
<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"
/>
</motion.div>
{/* 课程大纲侧边栏 */}
<CourseSyllabus
sections={course?.sections || []}
onLectureClick={handleLectureClick}
isOpen={isSyllabusOpen}
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
/>
</div>
);
}

View File

@ -0,0 +1,40 @@
import {
SkeletonItem,
SkeletonSection,
} from "@web/src/components/presentation/Skeleton";
export const CourseDetailSkeleton = () => {
return (
<div className="space-y-8">
{/* 标题骨架屏 */}
<div className="space-y-4">
<SkeletonItem className="h-9 w-3/4" />
<SkeletonItem className="h-6 w-1/2" delay={0.2} />
</div>
{/* 描述骨架屏 */}
<SkeletonSection items={2} />
{/* 学习目标骨架屏 */}
<SkeletonSection title items={4} gridCols />
{/* 适合人群骨架屏 */}
<SkeletonSection title items={4} gridCols />
{/* 技能骨架屏 */}
<div>
<SkeletonItem className="h-6 w-32 mb-4" />
<div className="flex flex-wrap gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<SkeletonItem
key={i}
className="h-8 w-20 rounded-full"
delay={i * 0.2}
/>
))}
</div>
</div>
</div>
);
};
export default CourseDetailSkeleton;

View File

@ -0,0 +1,214 @@
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>
);
};

View File

@ -0,0 +1,51 @@
// components/CourseVideoPage.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 { Course } from "@nice/common";
interface CourseVideoPageProps {
course: Course;
videoSrc?: string;
videoPoster?: string;
isLoading?: boolean;
}
export const CourseVideoPage: React.FC<CourseVideoPageProps> = ({
course,
videoSrc,
videoPoster,
isLoading = false,
}) => {
// 创建滚动动画效果
const { scrollY } = useScroll();
const videoScale = useTransform(scrollY, [0, 200], [1, 0.8]);
const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]);
return (
<div className="min-h-screen bg-gray-50">
{/* 固定的视频区域 */}
<motion.div
style={{
// scale: videoScale,
opacity: videoOpacity,
}}
className="sticky top-0 w-full bg-black z-10">
<div className="max-w-6xl mx-auto px-4">
<VideoPlayer src={videoSrc} poster={videoPoster} />
</div>
</motion.div>
{/* 课程内容区域 */}
<motion.div
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} />
</motion.div>
</div>
);
};
export default CourseVideoPage;

View File

@ -1,75 +0,0 @@
import { CheckCircleIcon } from '@heroicons/react/24/outline'
import { Course } from "@nice/common"
interface CourseDetailProps {
course: Course
}
export const CourseDetail: React.FC<CourseDetailProps> = ({ course }) => {
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>
)
}

View File

@ -1,78 +0,0 @@
import { ChevronDownIcon, ClockIcon, PlayCircleIcon } from '@heroicons/react/24/outline'
import { useState } from 'react'
import { Section, SectionDto } from "@nice/common"
interface CourseSyllabusProps {
sections: SectionDto[]
onLectureClick?: (lectureId: string) => void
}
export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
sections,
onLectureClick
}) => {
const [expandedSections, setExpandedSections] = useState<string[]>([])
const toggleSection = (sectionId: string) => {
setExpandedSections(prev =>
prev.includes(sectionId)
? prev.filter(id => id !== sectionId)
: [...prev, sectionId]
)
}
return (
<div className="space-y-4">
{sections.map((section) => (
<div key={section.id} className="border rounded-lg">
{/* 章节标题 */}
<button
className="w-full flex items-center justify-between p-4 hover:bg-gray-50"
onClick={() => toggleSection(section.id)}
>
<div className="flex items-center gap-4">
<span className="text-lg font-medium">
{Math.floor(section.order)}
</span>
<div>
<h3 className="text-left font-medium">{section.title}</h3>
<p className="text-sm text-gray-500">
{section.totalLectures} · {Math.floor(section.totalDuration / 60)}
</p>
</div>
</div>
<ChevronDownIcon
className={`w-5 h-5 transition-transform duration-200 ${expandedSections.includes(section.id) ? 'rotate-180' : ''
}`}
/>
</button>
{/* 课时列表 */}
{expandedSections.includes(section.id) && (
<div className="border-t">
{section.lectures.map((lecture) => (
<button
key={lecture.id}
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left"
onClick={() => onLectureClick?.(lecture.id)}
>
<PlayCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0" />
<div className="flex-grow">
<h4 className="font-medium">{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>
</button>
))}
</div>
)}
</div>
))}
</div>
)
}

View File

@ -0,0 +1,43 @@
// components/presentation/Skeleton.tsx
import { motion } from "framer-motion";
import React from "react";
export const SkeletonItem = ({
className,
delay = 0,
}: {
className: string;
delay?: number;
}) => (
<motion.div
className={`bg-gray-200 rounded-md ${className}`}
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
delay,
}}
/>
);
export const SkeletonSection = ({
title,
items,
gridCols = false,
}: {
title?: boolean;
items: number;
gridCols?: boolean;
}) => (
<div>
{title && <SkeletonItem className="h-6 w-32 mb-4" />}
<div
className={
gridCols ? "grid grid-cols-1 md:grid-cols-2 gap-4" : "space-y-2"
}>
{Array.from({ length: items }).map((_, i) => (
<SkeletonItem key={i} className="h-4" delay={i * 0.2} />
))}
</div>
</div>
);

View File

@ -0,0 +1,177 @@
// components/Popover.tsx
import { motion, AnimatePresence } from "framer-motion";
import React, {
ReactNode,
useState,
cloneElement,
isValidElement,
ReactElement,
} from "react";
type PopoverPosition = "top" | "bottom" | "left" | "right";
type TriggerType = "hover" | "click" | "focus";
interface PopoverProps {
title?: string;
content: ReactNode;
position?: PopoverPosition;
trigger?: TriggerType;
children: ReactNode;
// 可选的延迟时间(毫秒),用于 hover 模式
hoverDelay?: number;
}
const positionStyles = {
top: {
initial: { opacity: 0, y: 10, scale: 0.95 },
animate: { opacity: 1, y: 0, scale: 1 },
className: "bottom-full left-1/2 -translate-x-1/2 mb-2",
},
bottom: {
initial: { opacity: 0, y: -10, scale: 0.95 },
animate: { opacity: 1, y: 0, scale: 1 },
className: "top-full left-1/2 -translate-x-1/2 mt-2",
},
left: {
initial: { opacity: 0, x: 10, scale: 0.95 },
animate: { opacity: 1, x: 0, scale: 1 },
className: "right-full top-1/2 -translate-y-1/2 mr-2",
},
right: {
initial: { opacity: 0, x: -10, scale: 0.95 },
animate: { opacity: 1, x: 0, scale: 1 },
className: "left-full top-1/2 -translate-y-1/2 ml-2",
},
};
export const Popover: React.FC<PopoverProps> = ({
title,
content,
position = "right",
trigger = "hover",
children,
hoverDelay = 200,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [hoverTimeout, setHoverTimeout] = useState<number | null>(null);
const handleMouseEnter = () => {
if (trigger === "hover") {
if (hoverTimeout) window.clearTimeout(hoverTimeout);
setIsOpen(true);
}
};
const handleMouseLeave = () => {
if (trigger === "hover") {
// window.setTimeout 返回 number 类型
const timeout = window.setTimeout(
() => setIsOpen(false),
hoverDelay
);
setHoverTimeout(timeout);
}
};
const handleClick = (e: React.MouseEvent) => {
if (trigger === "click") {
setIsOpen(!isOpen);
}
};
const handleFocus = () => {
if (trigger === "focus") {
setIsOpen(true);
}
};
const handleBlur = () => {
if (trigger === "focus") {
setIsOpen(false);
}
};
// 添加类型断言来处理 children 的类型
const childrenWithProps = isValidElement(children)
? cloneElement(children as ReactElement<any>, {
onClick: (e: React.MouseEvent) => {
handleClick(e);
const child = children as ReactElement<{
onClick?: (e: React.MouseEvent) => void;
}>;
if (child.props.onClick) {
child.props.onClick(e);
}
},
onMouseEnter: (e: React.MouseEvent) => {
handleMouseEnter();
const child = children as ReactElement<{
onMouseEnter?: (e: React.MouseEvent) => void;
}>;
if (child.props.onMouseEnter) {
child.props.onMouseEnter(e);
}
},
onMouseLeave: (e: React.MouseEvent) => {
handleMouseLeave();
const child = children as ReactElement<{
onMouseLeave?: (e: React.MouseEvent) => void;
}>;
if (child.props.onMouseLeave) {
child.props.onMouseLeave(e);
}
},
onFocus: (e: React.FocusEvent) => {
handleFocus();
const child = children as ReactElement<{
onFocus?: (e: React.FocusEvent) => void;
}>;
if (child.props.onFocus) {
child.props.onFocus(e);
}
},
onBlur: (e: React.FocusEvent) => {
handleBlur();
const child = children as ReactElement<{
onBlur?: (e: React.FocusEvent) => void;
}>;
if (child.props.onBlur) {
child.props.onBlur(e);
}
},
})
: children;
return (
<div
className="relative inline-block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
{childrenWithProps}
<AnimatePresence>
{isOpen && (
<motion.div
initial={positionStyles[position].initial}
animate={positionStyles[position].animate}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className={`
absolute z-50 min-w-[200px] bg-white
rounded-lg shadow-lg border border-gray-200
${positionStyles[position].className}
`}>
{title && (
<div className="px-4 py-2 border-b border-gray-100">
<h3 className="font-medium text-gray-900">
{title}
</h3>
</div>
)}
<div className="p-4">{content}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};

View File

@ -0,0 +1,35 @@
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);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 flex items-center justify-center bg-black/40 backdrop-blur-sm z-10">
<div className="text-white text-center">
<motion.div
animate={{ rotate: 360 }}
transition={{
duration: 1,
repeat: Infinity,
ease: "linear",
}}
className="w-12 h-12 border-4 border-white border-t-transparent rounded-full mb-4"
/>
<p className="text-sm font-medium">
{loadingProgress > 0
? `加载中... ${loadingProgress}%`
: "准备中..."}
</p>
</div>
</motion.div>
);
};
export default LoadingOverlay;

View File

@ -0,0 +1,276 @@
import React, { useEffect, useRef, useContext, useState } from "react";
import Hls from "hls.js";
import { motion, AnimatePresence } from "framer-motion";
import {
PlayIcon,
PauseIcon,
SpeakerWaveIcon,
SpeakerXMarkIcon,
Cog6ToothIcon,
ArrowsPointingOutIcon,
ArrowsPointingInIcon,
} from "@heroicons/react/24/solid";
import "plyr/dist/plyr.css";
import { VideoPlayerContext } from "./VideoPlayer";
import { formatTime } from "./utlis";
import { PlaybackSpeed } from "./type";
export const Controls = () => {
const {
showControls,
setShowControls,
isSettingsOpen,
setIsSettingsOpen,
playbackSpeed,
setPlaybackSpeed,
videoRef,
isReady,
setIsReady,
isPlaying,
setIsPlaying,
bufferingState,
setBufferingState,
volume,
setVolume,
isMuted,
setIsMuted,
loadingProgress,
setLoadingProgress,
currentTime,
setCurrentTime,
duration,
setDuration,
brightness,
setBrightness,
isDragging,
setIsDragging,
isHovering,
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;
if (!isHovering && !isDragging) {
timer = window.setTimeout(() => {
setShowControls(false);
}, 2000);
}
return () => {
if (timer) window.clearTimeout(timer);
};
}, [isHovering, isDragging]);
return (
<motion.div
layoutId="video-controls"
initial={false}
animate={{
opacity: showControls ? 1 : 0,
y: showControls ? 0 : 20,
}}
transition={{
duration: 0.2,
ease: "easeInOut",
}}
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>
{/* 控制按钮区域 */}
<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>
{/* 时间显示 */}
{duration && (
<span className="text-white text-sm">
{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>
{/* 亮度调节 */}
<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>
{/* 全屏按钮 */}
<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>
</div>
</div>
</motion.div>
);
};
export default Controls;

View File

@ -0,0 +1,115 @@
import React, { createContext, ReactNode, useRef, useState } from "react";
import { PlaybackSpeed } from "./type";
import VideoPlayerLayout from "./VideoPlayerLayout";
interface VideoPlayerContextType {
src: string;
poster?: string;
onError?: (error: string) => void;
showControls: boolean;
setShowControls: React.Dispatch<React.SetStateAction<boolean>>;
isSettingsOpen: boolean;
setIsSettingsOpen: React.Dispatch<React.SetStateAction<boolean>>;
playbackSpeed: PlaybackSpeed;
setPlaybackSpeed: React.Dispatch<React.SetStateAction<PlaybackSpeed>>;
videoRef: React.RefObject<HTMLVideoElement>;
isReady: boolean;
setIsReady: React.Dispatch<React.SetStateAction<boolean>>;
isPlaying: boolean;
setIsPlaying: React.Dispatch<React.SetStateAction<boolean>>;
error: string | null;
setError: React.Dispatch<React.SetStateAction<string | null>>;
bufferingState: boolean;
setBufferingState: React.Dispatch<React.SetStateAction<boolean>>;
volume: number;
setVolume: React.Dispatch<React.SetStateAction<number>>;
isMuted: boolean;
setIsMuted: React.Dispatch<React.SetStateAction<boolean>>;
loadingProgress: number;
setLoadingProgress: React.Dispatch<React.SetStateAction<number>>;
currentTime: number;
setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
duration: number;
setDuration: React.Dispatch<React.SetStateAction<number>>;
brightness: number;
setBrightness: React.Dispatch<React.SetStateAction<number>>;
isDragging: boolean;
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
isHovering: boolean;
setIsHovering: React.Dispatch<React.SetStateAction<boolean>>;
progressRef: React.RefObject<HTMLDivElement>;
}
export const VideoPlayerContext = createContext<VideoPlayerContextType | null>(
null
);
export function VideoPlayer({
src,
poster,
onError,
}: {
src: string;
poster?: string;
onError?: (error: string) => void;
}) {
const [showControls, setShowControls] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState<PlaybackSpeed>(1.0);
const videoRef = useRef<HTMLVideoElement>(null);
const [isReady, setIsReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bufferingState, setBufferingState] = useState(false);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [loadingProgress, setLoadingProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [brightness, setBrightness] = useState(1);
const [isDragging, setIsDragging] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const progressRef = useRef<HTMLDivElement>(null);
return (
<VideoPlayerContext.Provider
value={{
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,
}}>
<VideoPlayerLayout></VideoPlayerLayout>
</VideoPlayerContext.Provider>
);
}

View File

@ -0,0 +1,34 @@
import { useContext } from "react";
import { VideoPlayerContext } from "./VideoPlayer";
import Controls from "./VideoControls";
import { AnimatePresence } from "framer-motion";
import { VideoScreen } from "./VideoScreen";
import LoadingOverlay from "./LoadingOverlay";
export default function VideoPlayerLayout() {
const {
isReady,
setIsHovering,
setShowControls,
showControls,
isDragging,
} = useContext(VideoPlayerContext);
return (
<>
<div
className={`relative aspect-video w-full bg-black rounded-lg overflow-hidden`}
onMouseEnter={() => {
setIsHovering(true);
setShowControls(true);
}}>
{!isReady && <div>123</div>}
{!isReady && <LoadingOverlay></LoadingOverlay>}
<VideoScreen></VideoScreen>
<AnimatePresence>
{(showControls || isDragging) && <Controls />}
</AnimatePresence>
</div>
</>
);
}

View File

@ -0,0 +1,226 @@
// 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 {
autoPlay?: boolean;
// className?: string;
// qualities?: { label: string; value: string }[];
// onQualityChange?: (quality: string) => void;
}
export const VideoScreen: React.FC<VideoScreenProps> = ({
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,
} = useContext(VideoPlayerContext);
// 处理进度条拖拽
const handleProgressDrag = (e: MouseEvent) => {
if (!isDragging || !videoRef.current || !progressRef.current) return;
const rect = progressRef.current.getBoundingClientRect();
const percent = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width)
);
videoRef.current.currentTime = percent * videoRef.current.duration;
};
// 添加拖拽事件监听
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]);
// 添加控制栏组件
useEffect(() => {
let hls: Hls;
const initializeHls = async () => {
if (!videoRef.current) return;
// Reset states
setIsReady(false);
setError(null);
setLoadingProgress(0);
setBufferingState(false);
// Check for native HLS support (Safari)
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
videoRef.current.src = src;
setIsReady(true);
if (autoPlay) {
try {
await videoRef.current.play();
setIsPlaying(true);
} catch (error) {
console.log("Auto-play prevented:", error);
}
}
return;
}
if (!Hls.isSupported()) {
const errorMessage = "您的浏览器不支持 HLS 视频播放";
setError(errorMessage);
onError?.(errorMessage);
return;
}
hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 600,
enableWorker: true,
debug: false,
});
hls.loadSource(src);
hls.attachMedia(videoRef.current);
hls.on(Hls.Events.MANIFEST_PARSED, async () => {
setIsReady(true);
if (autoPlay && videoRef.current) {
try {
await videoRef.current.play();
setIsPlaying(true);
} catch (error) {
console.log("Auto-play prevented:", error);
}
}
});
hls.on(Hls.Events.BUFFER_APPENDING, () => {
setBufferingState(true);
});
hls.on(Hls.Events.FRAG_BUFFERED, (_, data) => {
setBufferingState(false);
if (data.stats) {
const progress =
(data.stats.loaded / data.stats.total) * 100;
setLoadingProgress(Math.round(progress));
}
});
let networkError;
let fatalError;
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
networkError = `网络错误: ${data.details}`;
console.error(networkError);
setError(networkError);
onError?.(networkError);
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error("Media error, attempting to recover");
setError("视频解码错误,尝试恢复...");
hls.recoverMediaError();
break;
default:
fatalError = `加载失败: ${data.details}`;
console.error(fatalError);
setError(fatalError);
onError?.(fatalError);
hls.destroy();
break;
}
}
});
};
// Event handlers
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleEnded = () => setIsPlaying(false);
const handleWaiting = () => setBufferingState(true);
const handlePlaying = () => setBufferingState(false);
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);
}
initializeHls();
return () => {
if (videoRef.current) {
videoRef.current.removeEventListener("play", handlePlay);
videoRef.current.removeEventListener("pause", handlePause);
videoRef.current.removeEventListener("ended", handleEnded);
videoRef.current.removeEventListener("waiting", handleWaiting);
videoRef.current.removeEventListener("playing", handlePlaying);
}
if (hls) {
hls.destroy();
}
};
}, [src, onError, autoPlay]);
return (
<video
ref={videoRef}
className="w-full h-full"
poster={poster}
controls={false}
playsInline
muted={isMuted}
style={{ filter: `brightness(${brightness})` }}
onTimeUpdate={() => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
setDuration(videoRef.current.duration);
}
}}
/>
);
};

View File

@ -0,0 +1 @@
export type PlaybackSpeed = 0.5 | 0.75 | 1.0 | 1.25 | 1.5 | 2.0;

View File

@ -0,0 +1,5 @@
export const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
};

View File

@ -6,7 +6,6 @@
@apply border-b-2 border-primaryHover;
}
.ant-popover-inner {
padding: 0 !important;
@apply border border-gray-300;
@ -108,3 +107,37 @@
.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));
}

View File

@ -18,6 +18,7 @@ import { MainLayout } from "../components/layout/main/MainLayout";
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;
@ -53,29 +54,43 @@ export const routes: CustomRouteObject[] = [
children: [
{
index: true,
element: <HomePage />
element: <HomePage />,
},
{
path: "courses",
children: [
{
path: "student",
element: <WithAuth><StudentCoursesPage /></WithAuth>
element: (
<WithAuth>
<StudentCoursesPage />
</WithAuth>
),
},
{
path: "instructor",
element: <WithAuth><InstructorCoursesPage /></WithAuth>
}
]
}
]
element: (
<WithAuth>
<InstructorCoursesPage />
</WithAuth>
),
},
],
},
],
},
{
path: "course",
children: [{
path: ":id?/manage", // 使用 ? 表示 id 参数是可选的
element: <CourseEditorPage />
}]
children: [
{
path: ":id?/manage", // 使用 ? 表示 id 参数是可选的
element: <CourseEditorPage />,
},
{
path: ":id?/detail", // 使用 ? 表示 id 参数是可选的
element: <CourseDetailPage />,
},
],
},
{
path: "admin",

View File

@ -65,3 +65,32 @@ export const messageDetailSelect: Prisma.MessageSelect = {
option: true,
intent: true,
};
export const courseDetailSelect: Prisma.CourseSelect = {
id: true,
title: true,
subTitle: true,
description: true,
thumbnail: true,
level: true,
requirements: true,
objectives: true,
skills: true,
audiences: true,
totalDuration: true,
totalLectures: true,
averageRating: true,
numberOfReviews: true,
numberOfStudents: true,
completionRate: true,
status: true,
isFeatured: true,
createdAt: true,
publishedAt: true,
// 关联表选择
sections: {
include: {
lectures: true,
},
},
enrollments: true,
};

View File

@ -151,22 +151,22 @@ export type DepartmentDto = Department & {
children: DepartmentDto[];
hasChildren: boolean;
staffs: StaffDto[];
terms: TermDto[]
terms: TermDto[];
};
export type RoleMapDto = RoleMap & {
staff: StaffDto
}
staff: StaffDto;
};
export type SectionDto = Section & {
lectures: Lecture[]
}
lectures: Lecture[];
};
export type CourseDto = Course & {
enrollments: Enrollment[]
}
enrollments: Enrollment[];
sections: SectionDto[];
};
export interface BaseSetting {
appConfig?: {
splashScreen?: string;
devDept?: string;
};
}
export type RowModelResult = {

View File

@ -326,12 +326,18 @@ importers:
framer-motion:
specifier: ^11.15.0
version: 11.15.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
hls.js:
specifier: ^1.5.18
version: 1.5.18
idb-keyval:
specifier: ^6.2.1
version: 6.2.1
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
@ -401,7 +407,7 @@ importers:
version: 8.4.49
tailwindcss:
specifier: ^3.4.10
version: 3.4.17(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.7.2))
version: 3.4.17(ts-node@10.9.2(@swc/core@1.6.13)(@types/node@20.14.10)(typescript@5.7.2))
typescript:
specifier: ^5.5.4
version: 5.7.2
@ -3568,6 +3574,9 @@ 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==}
@ -3630,6 +3639,9 @@ 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'}
@ -3805,6 +3817,7 @@ packages:
encoding-down@6.3.0:
resolution: {integrity: sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==}
engines: {node: '>=6'}
deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
end-of-stream@1.4.4:
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
@ -4327,6 +4340,9 @@ packages:
resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==}
engines: {node: '>=8'}
hls.js@1.5.18:
resolution: {integrity: sha512-znxR+2jecWluu/0KOBqUcvVyAB5tLff10vjMGrpAlz1eFY+ZhF1bY3r82V+Bk7WJdk03iTjtja9KFFz5BrqjSA==}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
@ -4820,6 +4836,9 @@ 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'}
@ -5322,6 +5341,19 @@ 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'}
@ -5465,6 +5497,9 @@ 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'}
@ -5697,6 +5732,15 @@ 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'
@ -6483,6 +6527,9 @@ 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:
@ -10435,6 +10482,8 @@ snapshots:
dependencies:
toggle-selection: 1.0.6
core-js@3.39.0: {}
core-util-is@1.0.3: {}
cors@2.8.5:
@ -10515,6 +10564,8 @@ snapshots:
custom-error-instance@2.1.1: {}
custom-event-polyfill@1.0.7: {}
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.25.0
@ -11305,6 +11356,8 @@ snapshots:
hexoid@1.0.0: {}
hls.js@1.5.18: {}
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
@ -12005,6 +12058,8 @@ snapshots:
loader-runner@4.3.0: {}
loadjs@4.3.0: {}
locate-path@3.0.0:
dependencies:
p-locate: 3.0.0
@ -12443,6 +12498,21 @@ 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):
@ -12457,7 +12527,7 @@ snapshots:
camelcase-css: 2.0.1
postcss: 8.4.49
postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.7.2)):
postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@swc/core@1.6.13)(@types/node@20.14.10)(typescript@5.7.2)):
dependencies:
lilconfig: 3.1.3
yaml: 2.4.5
@ -12568,6 +12638,8 @@ snapshots:
range-parser@1.2.1: {}
rangetouch@2.0.1: {}
raw-body@2.5.2:
dependencies:
bytes: 3.1.2
@ -12896,6 +12968,10 @@ 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
@ -13482,7 +13558,7 @@ snapshots:
tailwind-merge@2.6.0: {}
tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.7.2)):
tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.6.13)(@types/node@20.14.10)(typescript@5.7.2)):
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
@ -13501,7 +13577,7 @@ snapshots:
postcss: 8.4.49
postcss-import: 15.1.0(postcss@8.4.49)
postcss-js: 4.0.1(postcss@8.4.49)
postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.7.2))
postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@swc/core@1.6.13)(@types/node@20.14.10)(typescript@5.7.2))
postcss-nested: 6.2.0(postcss@8.4.49)
postcss-selector-parser: 6.1.2
resolve: 1.22.8
@ -13792,6 +13868,8 @@ 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