10211504
This commit is contained in:
parent
4600362e4d
commit
001da175fb
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"marscode.chatLanguage": "cn",
|
||||||
|
"marscode.codeCompletionPro": {
|
||||||
|
"enableCodeCompletionPro": true
|
||||||
|
},
|
||||||
|
"marscode.enableInlineCommand": true
|
||||||
|
}
|
|
@ -26,8 +26,7 @@ export class GenDevService {
|
||||||
deptStaffRecord: Record<string, Staff[]> = {};
|
deptStaffRecord: Record<string, Staff[]> = {};
|
||||||
terms: Record<TaxonomySlug, Term[]> = {
|
terms: Record<TaxonomySlug, Term[]> = {
|
||||||
[TaxonomySlug.CATEGORY]: [],
|
[TaxonomySlug.CATEGORY]: [],
|
||||||
[TaxonomySlug.UNIT]: [],
|
[TaxonomySlug.TAG]: []
|
||||||
[TaxonomySlug.TAG]: [],
|
|
||||||
};
|
};
|
||||||
depts: Department[] = [];
|
depts: Department[] = [];
|
||||||
domains: Department[] = [];
|
domains: Department[] = [];
|
||||||
|
@ -67,7 +66,7 @@ export class GenDevService {
|
||||||
const domains = this.depts.filter((item) => item.isDomain);
|
const domains = this.depts.filter((item) => item.isDomain);
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
await this.createTerms(domain, TaxonomySlug.CATEGORY, depth, count);
|
await this.createTerms(domain, TaxonomySlug.CATEGORY, depth, count);
|
||||||
await this.createTerms(domain, TaxonomySlug.UNIT, depth, count);
|
// await this.createTerms(domain, TaxonomySlug.UNIT, depth, count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const termCount = await db.term.count();
|
const termCount = await db.term.count();
|
||||||
|
@ -204,6 +203,12 @@ export class GenDevService {
|
||||||
const taxonomy = await db.taxonomy.findFirst({
|
const taxonomy = await db.taxonomy.findFirst({
|
||||||
where: { slug: taxonomySlug },
|
where: { slug: taxonomySlug },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!taxonomy) {
|
||||||
|
throw new Error(`Taxonomy with slug ${taxonomySlug} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Creating terms for taxonomy: ${taxonomy.name} (${taxonomy.slug})`);
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
const createTermTree = async (
|
const createTermTree = async (
|
||||||
parentId: string | null,
|
parentId: string | null,
|
||||||
|
|
|
@ -23,6 +23,7 @@ export const LoginForm = ({ onSubmit, isLoading }: LoginFormProps) => {
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
size="large"
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onFinish={onSubmit}
|
onFinish={onSubmit}
|
||||||
>
|
>
|
||||||
|
@ -60,7 +61,7 @@ export const LoginForm = ({ onSubmit, isLoading }: LoginFormProps) => {
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
className="w-full h-10 rounded-lg"
|
className="w-full h-10 rounded-lg"
|
||||||
>
|
>
|
||||||
{isLoading ? "Signing in..." : "Sign In"}
|
{isLoading ? "登录中..." : "登录"}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
@ -1,107 +0,0 @@
|
||||||
|
|
||||||
import { Carousel } from '@web/src/components/common/element/Carousel';
|
|
||||||
import LeaderCard from '@web/src/components/common/element/LeaderCard';
|
|
||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import * as tus from 'tus-js-client';
|
|
||||||
interface TusUploadProps {
|
|
||||||
onSuccess?: (response: any) => void;
|
|
||||||
onError?: (error: Error) => void;
|
|
||||||
}
|
|
||||||
const carouselItems = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
image: 'https://th.bing.com/th/id/OIP.PEtRTLQwIX54HGFX5xNDYwHaE7?rs=1&pid=ImgDetMain',
|
|
||||||
title: '自然风光',
|
|
||||||
description: '壮丽的山川河流'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
image: 'https://eskipaper.com/images/scenery-pictures-15.jpg',
|
|
||||||
title: '城市景观',
|
|
||||||
description: '现代化的都市风貌'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
const TusUploader: React.FC<TusUploadProps> = ({
|
|
||||||
onSuccess,
|
|
||||||
onError
|
|
||||||
}) => {
|
|
||||||
const [progress, setProgress] = useState<number>(0);
|
|
||||||
const [isUploading, setIsUploading] = useState<boolean>(false);
|
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
||||||
const handleFileUpload = useCallback((file: File) => {
|
|
||||||
if (!file) return;
|
|
||||||
setIsUploading(true);
|
|
||||||
setProgress(0);
|
|
||||||
setUploadError(null);
|
|
||||||
// Extract file extension
|
|
||||||
const extension = file.name.split('.').pop() || '';
|
|
||||||
const upload = new tus.Upload(file, {
|
|
||||||
endpoint: "http://localhost:3000/upload",
|
|
||||||
retryDelays: [0, 1000, 3000, 5000],
|
|
||||||
metadata: {
|
|
||||||
filename: file.name,
|
|
||||||
size: file.size.toString(),
|
|
||||||
mimeType: file.type,
|
|
||||||
extension: extension,
|
|
||||||
modifiedAt: new Date(file.lastModified).toISOString(),
|
|
||||||
},
|
|
||||||
onProgress: (bytesUploaded, bytesTotal) => {
|
|
||||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
|
|
||||||
setProgress(Number(percentage));
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setIsUploading(false);
|
|
||||||
setProgress(100);
|
|
||||||
onSuccess && onSuccess(upload);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setIsUploading(false);
|
|
||||||
setUploadError(error.message);
|
|
||||||
onError && onError(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
upload.start();
|
|
||||||
},
|
|
||||||
[onSuccess, onError]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className=' flex flex-col gap-4'>
|
|
||||||
<Carousel
|
|
||||||
slides={carouselItems}
|
|
||||||
|
|
||||||
autoplayDelay={5000}
|
|
||||||
className="shadow-xl"
|
|
||||||
/>
|
|
||||||
<LeaderCard
|
|
||||||
name="张三"
|
|
||||||
title="技术总监"
|
|
||||||
description={
|
|
||||||
"负责公司技术战略规划"
|
|
||||||
}
|
|
||||||
imageUrl="https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) handleFileUpload(file);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isUploading && (
|
|
||||||
<div>
|
|
||||||
<progress value={progress} max="100" />
|
|
||||||
<span>{progress}%</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{uploadError && (
|
|
||||||
<div style={{ color: 'red' }}>
|
|
||||||
上传错误: {uploadError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TusUploader;
|
|
|
@ -13,7 +13,6 @@ export function Header() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-gradient-to-r from-primary to-primary-50 p-6">
|
<header className="bg-gradient-to-r from-primary to-primary-50 p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<h1 className="text-3xl font-bold text-white">公开信件列表</h1>
|
<h1 className="text-3xl font-bold text-white">公开信件列表</h1>
|
||||||
<div className="mt-4 text-blue-50">
|
<div className="mt-4 text-blue-50">
|
||||||
<p className="text-base opacity-90">
|
<p className="text-base opacity-90">
|
||||||
|
@ -34,7 +33,6 @@ export function Header() {
|
||||||
onCategoryChange={handleFilterChange}
|
onCategoryChange={handleFilterChange}
|
||||||
onStatusChange={handleFilterChange}
|
onStatusChange={handleFilterChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,60 +1,150 @@
|
||||||
import { StarIcon } from '@heroicons/react/24/outline';
|
import { EyeOutlined, LikeOutlined, LikeFilled, UserOutlined, BankOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Typography, Space, Tooltip } from 'antd';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { Letter } from './types';
|
import { Letter } from './types';
|
||||||
import { getBadgeStyle } from './utils';
|
import { getBadgeStyle } from './utils';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
interface LetterCardProps {
|
interface LetterCardProps {
|
||||||
letter: Letter;
|
letter: Letter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LetterCard({ letter }: LetterCardProps) {
|
export function LetterCard({ letter }: LetterCardProps) {
|
||||||
|
const [likes, setLikes] = useState(0);
|
||||||
|
const [liked, setLiked] = useState(false);
|
||||||
|
const [views] = useState(Math.floor(Math.random() * 100)); // 模拟浏览量数据
|
||||||
|
|
||||||
|
const handleLike = () => {
|
||||||
|
if (!liked) {
|
||||||
|
setLikes(prev => prev + 1);
|
||||||
|
setLiked(true);
|
||||||
|
toast.success('已点赞!', {
|
||||||
|
icon: <LikeFilled className="text-blue-500" />,
|
||||||
|
className: 'custom-message',
|
||||||
|
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLikes(prev => prev - 1);
|
||||||
|
setLiked(false);
|
||||||
|
toast('已取消点赞', {
|
||||||
|
className: 'custom-message',
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative border-b hover:bg-blue-100/30 p-6 transition-all duration-300 ease-in-out ">
|
<div
|
||||||
<div className="flex flex-col space-y-4">
|
className="w-full p-4 bg-white transition-all duration-300 ease-in-out hover:shadow-lg group"
|
||||||
{/* Header Section */}
|
>
|
||||||
<div className="flex justify-between items-center space-x-4">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center space-x-3">
|
{/* Title & Priority */}
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<Title level={4} className="!mb-0 flex-1">
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
|
||||||
href={`/letters/${letter.id}`}
|
href={`/letters/${letter.id}`}
|
||||||
className="text-xl font-semibold text-navy-900 group-hover:text-blue-700 transition-colors hover:underline"
|
target="_blank"
|
||||||
|
className="text-navy-900 transition-all duration-300 relative
|
||||||
|
before:absolute before:bottom-0 before:left-0 before:w-0 before:h-[2px] before:bg-blue-600
|
||||||
|
group-hover:before:w-full before:transition-all before:duration-300
|
||||||
|
group-hover:text-blue-600 group-hover:scale-105 group-hover:drop-shadow-md"
|
||||||
>
|
>
|
||||||
{letter.title}
|
{letter.title}
|
||||||
</a>
|
</a>
|
||||||
|
</Title>
|
||||||
</div>
|
{letter.priority && (
|
||||||
|
<Badge type="priority" value={letter.priority} className="ml-2" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Meta Information */}
|
{/* Meta Info */}
|
||||||
<div className="text-sm text-gray-600 flex items-center justify-between">
|
<div className="flex justify-between items-center text-sm text-gray-600">
|
||||||
<span className="font-medium text-gray-800">{letter.sender} | {letter.unit}</span>
|
<Space size="middle">
|
||||||
<span>{letter.date}</span>
|
<Space>
|
||||||
</div>
|
<UserOutlined className="text-gray-400" />
|
||||||
|
<Text strong>{letter.sender}</Text>
|
||||||
{/* Badges Section */}
|
</Space>
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
<Text type="secondary">|</Text>
|
||||||
<Badge type="category" value={letter.category} />
|
<Space>
|
||||||
<Badge type="status" value={letter.status} />
|
<BankOutlined className="text-gray-400" />
|
||||||
{letter.priority && <Badge type="priority" value={letter.priority} />}
|
<Text>{letter.unit}</Text>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<CalendarOutlined className="text-gray-400" />
|
||||||
|
<Text type="secondary">{letter.date}</Text>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Preview */}
|
{/* Content Preview */}
|
||||||
{letter.content && (
|
{letter.content && (
|
||||||
<p className="text-sm text-gray-700 line-clamp-2 leading-relaxed mt-2">
|
<div className="flex items-start gap-2">
|
||||||
|
<FileTextOutlined className="text-gray-400 mt-1" />
|
||||||
|
<Paragraph
|
||||||
|
ellipsis={{ rows: 2 }}
|
||||||
|
className="!mb-3 text-gray-600 flex-1"
|
||||||
|
>
|
||||||
{letter.content}
|
{letter.content}
|
||||||
</p>
|
</Paragraph>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Badges & Interactions */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Space size="small" wrap className="flex-1">
|
||||||
|
<Badge type="category" value={letter.category} />
|
||||||
|
<Badge type="status" value={letter.status} />
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1 text-gray-500">
|
||||||
|
<EyeOutlined className="text-lg" />
|
||||||
|
<span className="text-sm">{views}</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip
|
||||||
|
title={liked ? '取消点赞' : '点赞'}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type={liked ? 'primary' : 'default'}
|
||||||
|
shape="round"
|
||||||
|
size="small"
|
||||||
|
icon={liked ? <LikeFilled /> : <LikeOutlined />}
|
||||||
|
onClick={handleLike}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-1 px-3 transform transition-all duration-300
|
||||||
|
hover:scale-105 hover:shadow-md
|
||||||
|
${liked ? 'bg-blue-500 hover:bg-blue-600' : 'hover:border-blue-500 hover:text-blue-500'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className={liked ? 'text-white' : ''}>{likes}</span>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Badge({ type, value }: { type: 'priority' | 'category' | 'status'; value: string }) {
|
function Badge({
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
className = ''
|
||||||
|
}: {
|
||||||
|
type: 'priority' | 'category' | 'status';
|
||||||
|
value: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`
|
className={`
|
||||||
inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold
|
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
${getBadgeStyle(type, value)}
|
${getBadgeStyle(type, value)}
|
||||||
transition-all duration-150 ease-in-out transform hover:scale-105
|
transition-all duration-200 ease-in-out transform hover:scale-105
|
||||||
|
${className}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{value.toUpperCase()}
|
{value.toUpperCase()}
|
||||||
|
|
|
@ -22,7 +22,7 @@ export default function LetterListPage() {
|
||||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 flex flex-col">
|
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 flex flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */}
|
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */}
|
||||||
<div className="flex-grow">
|
<div className="flex-grow flex flex-col gap-2">
|
||||||
{filteredLetters.map((letter) => (
|
{filteredLetters.map((letter) => (
|
||||||
<LetterCard key={letter.id} letter={letter} />
|
<LetterCard key={letter.id} letter={letter} />
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { PaperAirplaneIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { Leader } from './types';
|
||||||
|
|
||||||
|
interface LeaderCardProps {
|
||||||
|
leader: Leader;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeaderCard({ leader, isSelected, onSelect }: LeaderCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
whileHover={{ scale: 1.005 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={`
|
||||||
|
bg-white rounded-xl shadow-sm overflow-hidden border border-gray-100
|
||||||
|
${isSelected
|
||||||
|
? 'ring-2 ring-[#00308F]'
|
||||||
|
: 'hover:shadow-lg hover:border-blue-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row">
|
||||||
|
{/* Image Container */}
|
||||||
|
<div className="sm:w-48 h-64 sm:h-auto flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={leader.imageUrl}
|
||||||
|
alt={leader.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Container */}
|
||||||
|
<div className="flex-1 p-6">
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900">
|
||||||
|
{leader.name}
|
||||||
|
</h3>
|
||||||
|
<span className="px-3 py-1 text-sm font-medium bg-blue-50 text-[#00308F] rounded-full">
|
||||||
|
{leader.rank}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 mb-4">{leader.division}</p>
|
||||||
|
|
||||||
|
{/* Contact Information */}
|
||||||
|
<div className="space-y-2 text-sm text-gray-600">
|
||||||
|
<p className="flex items-center">
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||||
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{leader.email}
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center">
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||||
|
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||||
|
</svg>
|
||||||
|
{leader.phone}
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center">
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
{leader.office}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className="mt-auto w-full sm:w-auto flex items-center justify-center gap-2
|
||||||
|
bg-[#00308F] text-white py-3 px-6 rounded-lg
|
||||||
|
hover:bg-[#002070] transition-all duration-300
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-[#00308F] focus:ring-opacity-50
|
||||||
|
transform hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
<PaperAirplaneIcon className="w-5 h-5" />
|
||||||
|
Compose Letter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,9 +2,17 @@ import { FunnelIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||||
import { leaders } from "./mock";
|
import { leaders } from "./mock";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Leader } from "./types";
|
import { Leader } from "./types";
|
||||||
|
import { Input, Select } from "antd";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
export default function Filter() {
|
const { Search } = Input;
|
||||||
const [selectedLeader, setSelectedLeader] = useState<Leader | null>(null);
|
|
||||||
|
interface FilterProps {
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
onDivisionChange?: (division: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Filter({ onSearch, onDivisionChange }: FilterProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedDivision, setSelectedDivision] = useState<string>('all');
|
const [selectedDivision, setSelectedDivision] = useState<string>('all');
|
||||||
|
|
||||||
|
@ -12,30 +20,51 @@ export default function Filter() {
|
||||||
return ['all', ...new Set(leaders.map(leader => leader.division))];
|
return ['all', ...new Set(leaders.map(leader => leader.division))];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <div className="flex flex-col md:flex-row gap-4 mb-8">
|
const handleSearch = (value: string) => {
|
||||||
<div className="relative flex-1">
|
setSearchQuery(value);
|
||||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-tertiary-300 w-5 h-5" />
|
onSearch?.(value);
|
||||||
<input
|
};
|
||||||
type="text"
|
|
||||||
placeholder="Search by name or rank..."
|
const handleDivisionChange = (value: string) => {
|
||||||
className="w-full pl-10 pr-4 py-3 rounded-lg border border-gray-200 focus:ring-2 focus:ring-[#00308F] focus:border-transparent"
|
setSelectedDivision(value);
|
||||||
value={searchQuery}
|
onDivisionChange?.(value);
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
};
|
||||||
/>
|
|
||||||
</div>
|
return (
|
||||||
<div className="relative">
|
<motion.div
|
||||||
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-tertiary-300 w-5 h-5" />
|
className="flex flex-col md:flex-row gap-4 mb-8"
|
||||||
<select
|
initial={{ opacity: 0, y: 20 }}
|
||||||
className="pl-10 pr-8 py-3 rounded-lg border border-gray-200 focus:ring-2 focus:ring-[#00308F] appearance-none bg-white"
|
animate={{ opacity: 1, y: 0 }}
|
||||||
value={selectedDivision}
|
transition={{ duration: 0.3 }}
|
||||||
onChange={(e) => setSelectedDivision(e.target.value)}
|
|
||||||
>
|
>
|
||||||
{divisions.map(division => (
|
<div className="flex-1">
|
||||||
<option key={division} value={division}>
|
<Search
|
||||||
{division === 'all' ? 'All Divisions' : division}
|
placeholder="Search by name or rank..."
|
||||||
</option>
|
allowClear
|
||||||
))}
|
enterButton={
|
||||||
</select>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<MagnifyingGlassIcon className="w-5 h-5" />
|
||||||
|
<span>Search</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
size="large"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
size="large"
|
||||||
|
value={selectedDivision}
|
||||||
|
onChange={handleDivisionChange}
|
||||||
|
suffixIcon={<FunnelIcon className="w-5 h-5 text-gray-400" />}
|
||||||
|
className="w-full md:w-64"
|
||||||
|
options={divisions.map(division => ({
|
||||||
|
value: division,
|
||||||
|
label: division === 'all' ? 'All Divisions' : division,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
return <header className="bg-gradient-to-r from-[#00308F] to-[#0353A4] text-white p-6">
|
return <header className="bg-gradient-to-r from-primary to-primary-50 text-white p-6">
|
||||||
<div className="flex flex-col space-y-6">
|
<div className="flex flex-col space-y-6">
|
||||||
{/* 主标题 */}
|
{/* 主标题 */}
|
||||||
<div>
|
<div>
|
||||||
|
@ -38,7 +38,7 @@ export default function Header() {
|
||||||
|
|
||||||
{/* 隐私承诺 */}
|
{/* 隐私承诺 */}
|
||||||
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
|
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
|
||||||
<p>我们承诺:您的个人信息将被严格保密,不向任何第三方透露。您可以选择匿名反映问题,平台会自动过滤可能暴露身份的信息。</p>
|
<p>您的个人信息将被严格保密,不向任何第三方透露。您可以选择匿名反映问题,平台会自动过滤可能暴露身份的信息。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { FunnelIcon, MagnifyingGlassIcon, PaperAirplaneIcon } from '@heroicons/react/24/outline';
|
|
||||||
import { Leader } from './types';
|
import { Leader } from './types';
|
||||||
import { leaders } from './mock';
|
import { leaders } from './mock';
|
||||||
import Header from './header';
|
import Header from './header';
|
||||||
import Filter from './filter';
|
import Filter from './filter';
|
||||||
|
import LeaderCard from './LeaderCard';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function WriteLetterPage() {
|
export default function WriteLetterPage() {
|
||||||
|
@ -26,104 +27,57 @@ export default function WriteLetterPage() {
|
||||||
}, [searchQuery, selectedDivision]);
|
}, [searchQuery, selectedDivision]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-slate-100 to-slate-200">
|
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100">
|
||||||
<Header></Header>
|
<Header />
|
||||||
{/* 搜索和筛选区域 */}
|
|
||||||
<div className=" px-4 py-8">
|
|
||||||
|
|
||||||
<Filter></Filter>
|
<div className="container mx-auto px-4 py-8">
|
||||||
{/* Modified Leader Cards Grid */}
|
<Filter />
|
||||||
<div className="grid grid-cols-1 gap-6">
|
|
||||||
{filteredLeaders.map((leader) => (
|
<AnimatePresence>
|
||||||
|
{filteredLeaders.length > 0 ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="grid grid-cols-1 gap-6"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
{filteredLeaders.map((leader) => (
|
||||||
|
<LeaderCard
|
||||||
key={leader.id}
|
key={leader.id}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
leader={leader}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
isSelected={selectedLeader?.id === leader.id}
|
||||||
whileHover={{ scale: 1.005 }}
|
onSelect={() => setSelectedLeader(leader)}
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className={`
|
|
||||||
bg-white rounded-xl shadow-sm overflow-hidden border border-gray-100
|
|
||||||
${selectedLeader?.id === leader.id
|
|
||||||
? 'ring-2 ring-[#00308F]'
|
|
||||||
: 'hover:shadow-lg hover:border-blue-100'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col sm:flex-row">
|
|
||||||
{/* Image Container */}
|
|
||||||
<div className="sm:w-48 h-64 sm:h-auto flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src={leader.imageUrl}
|
|
||||||
alt={leader.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Container */}
|
|
||||||
<div className="flex-1 p-6">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900">
|
|
||||||
{leader.name}
|
|
||||||
</h3>
|
|
||||||
<span className="px-3 py-1 text-sm font-medium bg-blue-50 text-[#00308F] rounded-full">
|
|
||||||
{leader.rank}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-600 mb-4">{leader.division}</p>
|
|
||||||
|
|
||||||
{/* Contact Information */}
|
|
||||||
<div className="space-y-2 text-sm text-gray-600">
|
|
||||||
<p className="flex items-center">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
|
||||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
{leader.email}
|
|
||||||
</p>
|
|
||||||
<p className="flex items-center">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
|
||||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
|
||||||
</svg>
|
|
||||||
{leader.phone}
|
|
||||||
</p>
|
|
||||||
<p className="flex items-center">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
|
||||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
|
||||||
</svg>
|
|
||||||
{leader.office}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedLeader(leader)}
|
|
||||||
className="mt-auto w-full sm:w-auto flex items-center justify-center gap-2
|
|
||||||
bg-[#00308F] text-white py-3 px-6 rounded-lg
|
|
||||||
hover:bg-[#002070] transition-all duration-300
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-[#00308F] focus:ring-opacity-50
|
|
||||||
transform hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
<PaperAirplaneIcon className="w-5 h-5" />
|
|
||||||
Compose Letter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</motion.div>
|
||||||
{/* 无结果提示 */}
|
) : (
|
||||||
{filteredLeaders.length === 0 && (
|
<motion.div
|
||||||
<div className="text-center py-12">
|
className="text-center py-12"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
<div className="inline-flex flex-col items-center gap-4">
|
||||||
|
<svg
|
||||||
|
className="w-16 h-16 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<p className="text-gray-600 text-lg">
|
<p className="text-gray-600 text-lg">
|
||||||
No leaders found matching your search criteria
|
No leaders found matching your search criteria
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { HTMLMotionProps, motion } from 'framer-motion';
|
import { HTMLMotionProps, motion } from 'framer-motion';
|
||||||
import { forwardRef, ReactNode } from 'react';
|
import { forwardRef, ReactNode } from 'react';
|
||||||
import { cn } from '@web/src/utils/classname';
|
|
||||||
import { LoadingOutlined } from '@ant-design/icons';
|
import { LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, keyof React.ButtonHTMLAttributes<HTMLButtonElement>> {
|
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, keyof React.ButtonHTMLAttributes<HTMLButtonElement>> {
|
||||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning' | 'info' | 'light' | 'dark' |
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning' | 'info' | 'light' | 'dark' |
|
||||||
|
@ -162,7 +162,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
ref={ref}
|
ref={ref}
|
||||||
title={title}
|
title={title}
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
className={cn(
|
className={twMerge(
|
||||||
// Base styles
|
// Base styles
|
||||||
'relative inline-flex items-center justify-center font-medium',
|
'relative inline-flex items-center justify-center font-medium',
|
||||||
'transition-all duration-200 ease-in-out',
|
'transition-all duration-200 ease-in-out',
|
||||||
|
@ -187,7 +187,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
>
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<LoadingOutlined
|
<LoadingOutlined
|
||||||
className={cn(
|
className={twMerge(
|
||||||
"animate-spin",
|
"animate-spin",
|
||||||
!isIconOnly && "mr-2",
|
!isIconOnly && "mr-2",
|
||||||
iconSizes[size]
|
iconSizes[size]
|
||||||
|
@ -196,7 +196,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isLoading && leftIcon && (
|
{!isLoading && leftIcon && (
|
||||||
<span className={cn(
|
<span className={twMerge(
|
||||||
"inline-flex",
|
"inline-flex",
|
||||||
!isIconOnly && "mr-2",
|
!isIconOnly && "mr-2",
|
||||||
iconSizes[size]
|
iconSizes[size]
|
||||||
|
@ -206,7 +206,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
{!isLoading && rightIcon && !isIconOnly && (
|
{!isLoading && rightIcon && !isIconOnly && (
|
||||||
<span className={cn(
|
<span className={twMerge(
|
||||||
"inline-flex ml-2",
|
"inline-flex ml-2",
|
||||||
iconSizes[size]
|
iconSizes[size]
|
||||||
)}>
|
)}>
|
||||||
|
|
|
@ -1,74 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
|
|
||||||
type LeaderCardProps = {
|
|
||||||
name: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
imageUrl: string;
|
|
||||||
|
|
||||||
onClick?: () => void; // 添加整个卡片的点击事件
|
|
||||||
};
|
|
||||||
|
|
||||||
const LeaderCard: React.FC<LeaderCardProps> = ({
|
|
||||||
name,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
imageUrl,
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="group flex bg-white rounded-2xl shadow-md hover:shadow-2xl transition-all duration-300 ease-out max-w-2xl border border-gray-100 hover:border-blue-100 cursor-pointer overflow-hidden"
|
|
||||||
onClick={onClick}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
onClick?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Image Section */}
|
|
||||||
<div className="relative flex-shrink-0 overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt={name}
|
|
||||||
className="w-44 h-64 object-cover transition-transform duration-500 group-hover:scale-105"
|
|
||||||
style={{
|
|
||||||
aspectRatio: "3/4",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Section */}
|
|
||||||
<div className="flex flex-col justify-between p-5 flex-1">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Name and Title */}
|
|
||||||
<div className="transform transition-transform duration-300 group-hover:translate-x-2">
|
|
||||||
<h3 className="text-2xl font-semibold text-gray-800 mb-1 tracking-tight group-hover:text-primary-600 transition-colors">
|
|
||||||
{name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-base font-medium text-primary-600 tracking-wide group-hover:text-blue-500">
|
|
||||||
{title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p className="text-gray-600 text-base leading-relaxed line-clamp-4 font-light group-hover:text-gray-700">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Decorative Element */}
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="h-0.5 w-8 bg-blue-600 rounded-full transform origin-left transition-all duration-300 group-hover:w-16 group-hover:bg-gradient-to-r from-blue-600 to-blue-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default LeaderCard;
|
|
|
@ -19,10 +19,10 @@ export function Footer() {
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-white font-bold text-lg tracking-wide mb-2
|
<h3 className="text-white font-bold text-lg tracking-wide mb-2
|
||||||
drop-shadow-md">
|
drop-shadow-md">
|
||||||
美国空军官方领导机关信箱
|
官方领导机关信箱
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-tertiary-300 text-sm">
|
<p className="text-tertiary-300 text-sm">
|
||||||
为美国空军提供安全可靠的通信服务
|
为提供安全可靠的通信服务
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,19 @@
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import { useNavItem } from "./useNavItem";
|
import { useNavItem } from "./useNavItem";
|
||||||
|
|
||||||
export default function Navigation() {
|
interface NavigationProps {
|
||||||
const { navItems } = useNavItem()
|
className?: string;
|
||||||
return <nav className="mt-4 rounded-lg bg-[#0B1A32]/90">
|
}
|
||||||
<div className="flex items-center justify-between px-6 py-1">
|
|
||||||
<div className="flex space-x-1">
|
export default function Navigation({ className }: NavigationProps) {
|
||||||
|
const { navItems } = useNavItem();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={`mt-4 rounded-lg bg-[#0B1A32]/90 ${className}`}>
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch">
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<div className="hidden md:flex items-center px-6 py-1 w-full overflow-x-auto scrollbar-thin scrollbar-thumb-[#00308F] scrollbar-track-transparent">
|
||||||
|
<div className="flex space-x-4 min-w-max">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
|
@ -13,16 +21,17 @@ export default function Navigation() {
|
||||||
className={({ isActive }) => `
|
className={({ isActive }) => `
|
||||||
group relative px-4 py-3
|
group relative px-4 py-3
|
||||||
transition-all duration-300 ease-out
|
transition-all duration-300 ease-out
|
||||||
|
|
||||||
${isActive ? 'text-white font-medium' : 'text-[#8EADD4]'}
|
${isActive ? 'text-white font-medium' : 'text-[#8EADD4]'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<span className="relative z-10 transition-colors group-hover:text-white">
|
<span className="relative z-10 transition-colors group-hover:text-white flex items-center gap-2">
|
||||||
|
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<span className={`
|
<span
|
||||||
|
className={`
|
||||||
absolute bottom-1.5 left-1/2 h-[2px] bg-white
|
absolute bottom-1.5 left-1/2 h-[2px] bg-white
|
||||||
transition-all duration-300 ease-out
|
transition-all duration-300 ease-out
|
||||||
transform -translate-x-1/2
|
transform -translate-x-1/2
|
||||||
|
@ -30,12 +39,14 @@ export default function Navigation() {
|
||||||
? 'w-12 opacity-100'
|
? 'w-12 opacity-100'
|
||||||
: 'w-0 opacity-0 group-hover:w-8 group-hover:opacity-50'
|
: 'w-0 opacity-0 group-hover:w-8 group-hover:opacity-50'
|
||||||
}
|
}
|
||||||
`} />
|
`}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
|
</nav>)
|
||||||
}
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface MenuItemType {
|
||||||
|
icon: JSX.Element;
|
||||||
|
label: string;
|
||||||
|
action: () => void;
|
||||||
|
}
|
|
@ -1,16 +1,7 @@
|
||||||
import { api } from "@nice/client";
|
import { api } from "@nice/client";
|
||||||
import { TaxonomySlug } from "@nice/common";
|
import { TaxonomySlug } from "@nice/common";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
// export const NAV_ITEMS = [
|
|
||||||
|
|
||||||
// { to: "/write-letter", label: "咨询求助" },
|
|
||||||
// { to: "/write-letter", label: "解难帮困" },
|
|
||||||
// { to: "/write-letter", label: "需求提报" },
|
|
||||||
// { to: "/write-letter", label: "意见建议" },
|
|
||||||
// { to: "/write-letter", label: "问题反映" },
|
|
||||||
// { to: "/write-letter", label: "举报投诉" },
|
|
||||||
|
|
||||||
// ] as const;
|
|
||||||
export function useNavItem() {
|
export function useNavItem() {
|
||||||
const { data } = api.term.findMany.useQuery({
|
const { data } = api.term.findMany.useQuery({
|
||||||
where: {
|
where: {
|
||||||
|
@ -21,7 +12,7 @@ export function useNavItem() {
|
||||||
const navItems = useMemo(() => {
|
const navItems = useMemo(() => {
|
||||||
// 定义固定的导航项
|
// 定义固定的导航项
|
||||||
const staticItems = {
|
const staticItems = {
|
||||||
letterList: { to: "/letter-list", label: "公开信件" },
|
letterList: { to: "/", label: "公开信件" },
|
||||||
letterProgress: { to: "/letter-progress", label: "进度查询" },
|
letterProgress: { to: "/letter-progress", label: "进度查询" },
|
||||||
help: { to: "/help", label: "使用帮助" }
|
help: { to: "/help", label: "使用帮助" }
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,106 +1,214 @@
|
||||||
// ... existing imports ...
|
|
||||||
|
|
||||||
import { UserCircleIcon, Cog6ToothIcon, QuestionMarkCircleIcon, ArrowLeftStartOnRectangleIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { useClickOutside } from "@web/src/hooks/useClickOutside";
|
import { useClickOutside } from "@web/src/hooks/useClickOutside";
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useCallback, useMemo } from "react";
|
||||||
import { Avatar } from "../../common/element/Avatar";
|
import { Avatar } from "../../common/element/Avatar";
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
LogoutOutlined
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import type { MenuItemType } from "./types";
|
||||||
|
import { Spin } from "antd";
|
||||||
|
|
||||||
|
// USAF Theme Constants
|
||||||
|
const USAF_THEME = {
|
||||||
|
colors: {
|
||||||
|
primary: '#00538E', // Air Force Blue
|
||||||
|
secondary: '#003F6A', // Darker Blue
|
||||||
|
accent: '#B22234', // Air Force Red
|
||||||
|
background: '#F6F9FC', // Light Blue tint
|
||||||
|
hover: '#E6EEF5', // Lighter hover state
|
||||||
|
border: '#E5EDF5', // Light Border
|
||||||
|
text: {
|
||||||
|
primary: '#00538E',
|
||||||
|
secondary: '#4A5568',
|
||||||
|
light: '#718096',
|
||||||
|
danger: '#B22234'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shadows: {
|
||||||
|
sm: '0 2px 4px 0 rgba(0, 83, 142, 0.08)',
|
||||||
|
md: '0 4px 8px -1px rgba(0, 83, 142, 0.15), 0 2px 4px -1px rgba(0, 83, 142, 0.08)',
|
||||||
|
lg: '0 12px 20px -3px rgba(0, 83, 142, 0.15), 0 4px 8px -2px rgba(0, 83, 142, 0.1)',
|
||||||
|
hover: '0 6px 12px -2px rgba(0, 83, 142, 0.12), 0 3px 6px -1px rgba(0, 83, 142, 0.07)'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const menuVariants = {
|
||||||
|
hidden: { opacity: 0, scale: 0.95, y: -10 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.95,
|
||||||
|
y: -10,
|
||||||
|
transition: {
|
||||||
|
duration: 0.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function UserMenu() {
|
export function UserMenu() {
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const { user, logout } = useAuth();
|
const { user, logout, isLoading } = useAuth();
|
||||||
|
|
||||||
useClickOutside(menuRef, () => setShowMenu(false));
|
useClickOutside(menuRef, () => setShowMenu(false));
|
||||||
|
|
||||||
const menuItems = [
|
const toggleMenu = useCallback(() => {
|
||||||
|
setShowMenu(prev => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const menuItems: MenuItemType[] = useMemo(() => [
|
||||||
{
|
{
|
||||||
icon: <UserCircleIcon className="w-5 h-5" />,
|
icon: <UserOutlined className="text-lg" />,
|
||||||
label: '个人信息',
|
label: '个人信息',
|
||||||
action: () => { },
|
action: () => { },
|
||||||
color: 'text-primary-600'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Cog6ToothIcon className="w-5 h-5" />,
|
icon: <SettingOutlined className="text-lg" />,
|
||||||
label: '设置',
|
label: '设置',
|
||||||
action: () => { },
|
action: () => { },
|
||||||
color: 'text-gray-600'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <QuestionMarkCircleIcon className="w-5 h-5" />,
|
icon: <QuestionCircleOutlined className="text-lg" />,
|
||||||
label: '帮助',
|
label: '帮助',
|
||||||
action: () => { },
|
action: () => { },
|
||||||
color: 'text-gray-600'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <ArrowLeftStartOnRectangleIcon className="w-5 h-5" />,
|
icon: <LogoutOutlined className="text-lg" />,
|
||||||
label: '注销',
|
label: '注销',
|
||||||
action: () => logout(),
|
action: () => logout(),
|
||||||
color: 'text-red-600'
|
|
||||||
},
|
},
|
||||||
];
|
], [logout]);
|
||||||
|
|
||||||
|
const handleMenuItemClick = useCallback((action: () => void) => {
|
||||||
|
action();
|
||||||
|
setShowMenu(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center w-10 h-10">
|
||||||
|
<Spin size="small" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={menuRef} className="relative">
|
<div ref={menuRef} className="relative">
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
aria-label="用户菜单"
|
||||||
whileTap={{ scale: 0.95 }}
|
aria-haspopup="true"
|
||||||
onClick={() => setShowMenu(!showMenu)}
|
aria-expanded={showMenu}
|
||||||
|
aria-controls="user-menu"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={toggleMenu}
|
||||||
className="relative rounded-full focus:outline-none
|
className="relative rounded-full focus:outline-none
|
||||||
focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2
|
||||||
focus:ring-offset-[#13294B]"
|
focus:ring-offset-white transition-all duration-200 ease-in-out"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={user?.avatar}
|
src={user?.avatar}
|
||||||
name={user?.showname || user?.username}
|
name={user?.showname || user?.username}
|
||||||
size={40}
|
size={40}
|
||||||
className="ring-2 ring-white/80 hover:ring-blue-400
|
className="ring-2 ring-white hover:ring-[#00538E]/90
|
||||||
transition-all duration-300"
|
transition-all duration-200 ease-in-out shadow-md
|
||||||
|
hover:shadow-lg"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute bottom-0 right-0 h-3 w-3
|
||||||
|
rounded-full bg-emerald-500 ring-2 ring-white
|
||||||
|
shadow-sm transition-transform duration-200
|
||||||
|
ease-in-out hover:scale-110"
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="absolute bottom-0 right-0 h-3 w-3
|
|
||||||
rounded-full bg-green-500 ring-2 ring-white" />
|
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showMenu && (
|
{showMenu && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
initial="hidden"
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate="visible"
|
||||||
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
exit="exit"
|
||||||
transition={{
|
variants={menuVariants}
|
||||||
duration: 0.2,
|
role="menu"
|
||||||
type: "spring",
|
id="user-menu"
|
||||||
stiffness: 300,
|
aria-orientation="vertical"
|
||||||
damping: 30
|
aria-labelledby="user-menu-button"
|
||||||
}}
|
|
||||||
style={{ zIndex: 100 }}
|
style={{ zIndex: 100 }}
|
||||||
className="absolute right-0 mt-2 w-56 origin-top-right
|
className="absolute right-0 mt-3 w-64 origin-top-right
|
||||||
bg-white rounded-xl shadow-lg ring-1 ring-black/5
|
bg-white rounded-xl overflow-hidden shadow-lg
|
||||||
overflow-hidden"
|
border border-[#E5EDF5]"
|
||||||
>
|
>
|
||||||
<div className="p-4 border-b border-gray-100">
|
{/* User Profile Section */}
|
||||||
<h4 className="text-sm font-semibold text-gray-900">
|
<div
|
||||||
{user?.showname}
|
className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
|
||||||
</h4>
|
border-b border-[#E5EDF5] "
|
||||||
<p className="text-xs text-tertiary-300 mt-1">
|
|
||||||
{user?.username}
|
>
|
||||||
</p>
|
<div className="flex items-center space-x-4">
|
||||||
|
<Avatar
|
||||||
|
src={user?.avatar}
|
||||||
|
name={user?.showname || user?.username}
|
||||||
|
size={40}
|
||||||
|
className="ring-2 ring-white shadow-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col space-y-0.5">
|
||||||
|
<span className="text-sm font-semibold text-[#00538E]">
|
||||||
|
{user?.showname || user?.username}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[#718096] flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||||
|
在线
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Menu Items */}
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{menuItems.map((item, index) => (
|
{menuItems.map((item, index) => (
|
||||||
<motion.button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
whileHover={{ x: 4, backgroundColor: '#F3F4F6' }}
|
role="menuitem"
|
||||||
onClick={item.action}
|
tabIndex={showMenu ? 0 : -1}
|
||||||
className={`flex items-center gap-3 w-full p-2.5
|
onClick={(e) => {
|
||||||
rounded-lg text-sm font-medium
|
e.stopPropagation();
|
||||||
transition-colors duration-200
|
handleMenuItemClick(item.action);
|
||||||
${item.color} hover:bg-gray-100`}
|
}}
|
||||||
|
className={`flex items-center gap-3 w-full px-4 py-3
|
||||||
|
text-sm font-medium rounded-lg transition-all
|
||||||
|
focus:outline-none
|
||||||
|
focus:ring-2 focus:ring-[#00538E]/20
|
||||||
|
group relative overflow-hidden
|
||||||
|
active:scale-[0.99]
|
||||||
|
${item.label === '注销'
|
||||||
|
? 'text-[#B22234] hover:bg-red-50/80 hover:text-red-700'
|
||||||
|
: 'text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
|
<span className={`w-5 h-5 flex items-center justify-center
|
||||||
|
transition-all duration-200 ease-in-out
|
||||||
|
group-hover:scale-110 group-hover:rotate-6
|
||||||
|
group-hover:translate-x-0.5 ${item.label === '注销'
|
||||||
|
? 'group-hover:text-red-600'
|
||||||
|
: 'group-hover:text-[#003F6A]'}`}>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
|
</span>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</motion.button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
import { CourseDto } from "@nice/common";
|
|
||||||
import { Card } from "@web/src/components/common/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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,58 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CourseHeader = ({
|
|
||||||
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-tertiary-300 ">
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,59 +0,0 @@
|
||||||
import { StarIcon, ChartBarIcon, ClockIcon } from '@heroicons/react/24/solid';
|
|
||||||
|
|
||||||
interface CourseStatsProps {
|
|
||||||
averageRating?: number;
|
|
||||||
numberOfReviews?: number;
|
|
||||||
completionRate?: number;
|
|
||||||
totalDuration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CourseStats = ({
|
|
||||||
averageRating,
|
|
||||||
numberOfReviews,
|
|
||||||
completionRate,
|
|
||||||
totalDuration,
|
|
||||||
}: CourseStatsProps) => {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-2 gap-4 p-6 bg-gray-50 ">
|
|
||||||
{averageRating !== undefined && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StarIcon className="h-5 w-5 text-yellow-400" />
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-gray-900 ">
|
|
||||||
{averageRating.toFixed(1)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-tertiary-300 ">
|
|
||||||
{numberOfReviews} 观看量
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{completionRate !== undefined && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ChartBarIcon className="h-5 w-5 text-green-500" />
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-gray-900 ">
|
|
||||||
{completionRate}%
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-tertiary-300 ">
|
|
||||||
完成率
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{totalDuration !== undefined && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ClockIcon className="h-5 w-5 text-blue-500" />
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-gray-900 ">
|
|
||||||
{Math.floor(totalDuration / 60)}h {totalDuration % 60}m
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-tertiary-300 ">
|
|
||||||
总时长
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,17 +0,0 @@
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,54 +0,0 @@
|
||||||
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;
|
|
||||||
isHeaderVisible: boolean; // 新增
|
|
||||||
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增
|
|
||||||
return (
|
|
||||||
<CourseDetailContext.Provider
|
|
||||||
value={{
|
|
||||||
editId,
|
|
||||||
course,
|
|
||||||
selectedLectureId,
|
|
||||||
setSelectedLectureId,
|
|
||||||
isLoading,
|
|
||||||
isHeaderVisible,
|
|
||||||
setIsHeaderVisible,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</CourseDetailContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,29 +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";
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,47 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,67 +0,0 @@
|
||||||
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,54 +0,0 @@
|
||||||
// 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 { CourseDetailDescription } from "./CourseDetailDescription/CourseDetailDescription";
|
|
||||||
import { Course } from "@nice/common";
|
|
||||||
|
|
||||||
interface CourseDetailDisplayAreaProps {
|
|
||||||
course: Course;
|
|
||||||
videoSrc?: string;
|
|
||||||
videoPoster?: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CourseDetailDisplayArea: React.FC<
|
|
||||||
CourseDetailDisplayAreaProps
|
|
||||||
> = ({ 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">
|
|
||||||
{/* 固定的视频区域 */}
|
|
||||||
{/* 移除 sticky 定位,让视频区域随页面滚动 */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
style={{
|
|
||||||
opacity: videoOpacity,
|
|
||||||
}}
|
|
||||||
className="w-full bg-black">
|
|
||||||
<div className=" w-full ">
|
|
||||||
<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="w-full">
|
|
||||||
<CourseDetailDescription
|
|
||||||
course={course}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default CourseDetailDisplayArea;
|
|
|
@ -1,57 +0,0 @@
|
||||||
// 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,48 +0,0 @@
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { useContext, useState } from "react";
|
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
|
||||||
|
|
||||||
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
|
||||||
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
|
||||||
import CourseDetailHeader from "./CourseDetailHeader/CourseDetailHeader";
|
|
||||||
|
|
||||||
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">
|
|
||||||
<CourseDetailHeader /> {/* 添加 Header 组件 */}
|
|
||||||
{/* 主内容区域 */}
|
|
||||||
{/* 为了防止 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)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
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;
|
|
|
@ -1,19 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
|
@ -1,105 +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, 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,36 +0,0 @@
|
||||||
// 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-tertiary-300 mt-1">
|
|
||||||
{lecture.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 text-sm text-tertiary-300">
|
|
||||||
<ClockIcon className="w-4 h-4" />
|
|
||||||
<span>{lecture.duration}分钟</span>
|
|
||||||
</div>
|
|
||||||
</motion.button>
|
|
||||||
);
|
|
|
@ -1,68 +0,0 @@
|
||||||
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-tertiary-300">
|
|
||||||
{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-tertiary-300" />
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
);
|
|
|
@ -1,18 +0,0 @@
|
||||||
// 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>
|
|
||||||
);
|
|
|
@ -1 +0,0 @@
|
||||||
export * from "./CourseSyllabus";
|
|
|
@ -1,29 +0,0 @@
|
||||||
import { CheckOutlined } from '@ant-design/icons';
|
|
||||||
import React from 'react';
|
|
||||||
interface CourseObjectivesProps {
|
|
||||||
objectives: string[];
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
const CourseObjectives: React.FC<CourseObjectivesProps> = ({
|
|
||||||
objectives,
|
|
||||||
title = "您将会学到"
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<h2 className="text-xl font-bold mb-4">{title}</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{objectives.map((objective, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-start space-x-3"
|
|
||||||
>
|
|
||||||
<CheckOutlined></CheckOutlined>
|
|
||||||
<span className="text-gray-700">{objective}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CourseObjectives;
|
|
|
@ -1,110 +0,0 @@
|
||||||
import { createContext, useContext, ReactNode, useEffect } from "react";
|
|
||||||
import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
// import { CourseDto, CourseLevel, CourseStatus } from "@nice/common";
|
|
||||||
// import { api, useCourse } from "@nice/client";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
// 定义课程表单验证 Schema
|
|
||||||
const courseSchema = z.object({
|
|
||||||
title: z.string().min(1, "课程标题不能为空"),
|
|
||||||
subTitle: z.string().nullish(),
|
|
||||||
description: z.string().nullish(),
|
|
||||||
thumbnail: z.string().url().nullish(),
|
|
||||||
// level: z.nativeEnum(CourseLevel),
|
|
||||||
requirements: z.array(z.string()).nullish(),
|
|
||||||
objectives: z.array(z.string()).nullish(),
|
|
||||||
});
|
|
||||||
export type CourseFormData = z.infer<typeof courseSchema>;
|
|
||||||
interface CourseEditorContextType {
|
|
||||||
onSubmit: SubmitHandler<CourseFormData>;
|
|
||||||
editId?: string; // 添加 editId
|
|
||||||
part?: string;
|
|
||||||
// course?: CourseDto;
|
|
||||||
}
|
|
||||||
interface CourseFormProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
editId?: string; // 添加 editId 参数
|
|
||||||
part?: string;
|
|
||||||
}
|
|
||||||
const CourseEditorContext = createContext<CourseEditorContextType | null>(null);
|
|
||||||
export function CourseFormProvider({
|
|
||||||
children,
|
|
||||||
editId,
|
|
||||||
}: CourseFormProviderProps) {
|
|
||||||
// const { create, update } = useCourse()
|
|
||||||
// const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) })
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const methods = useForm<CourseFormData>({
|
|
||||||
resolver: zodResolver(courseSchema),
|
|
||||||
defaultValues: {
|
|
||||||
// level: CourseLevel.BEGINNER,
|
|
||||||
requirements: [],
|
|
||||||
objectives: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// useEffect(() => {
|
|
||||||
// if (course) {
|
|
||||||
// const formData = {
|
|
||||||
// title: course.title,
|
|
||||||
// subTitle: course.subTitle,
|
|
||||||
// description: course.description,
|
|
||||||
// thumbnail: course.thumbnail,
|
|
||||||
// level: course.level,
|
|
||||||
// requirements: course.requirements,
|
|
||||||
// objectives: course.objectives,
|
|
||||||
// status: course.status,
|
|
||||||
// };
|
|
||||||
// methods.reset(formData as any);
|
|
||||||
// }
|
|
||||||
// }, [course, methods]);
|
|
||||||
const onSubmit: SubmitHandler<CourseFormData> = async (
|
|
||||||
data: CourseFormData
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
if (editId) {
|
|
||||||
// await update.mutateAsync({
|
|
||||||
// where: { id: editId },
|
|
||||||
// data: {
|
|
||||||
// ...data
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
toast.success("课程更新成功!");
|
|
||||||
} else {
|
|
||||||
// const result = await create.mutateAsync({
|
|
||||||
// data: {
|
|
||||||
// status: CourseStatus.DRAFT,
|
|
||||||
// ...data
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// navigate(`/course/${result.id}/editor`, { replace: true });
|
|
||||||
toast.success("课程创建成功!");
|
|
||||||
}
|
|
||||||
methods.reset(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error submitting form:", error);
|
|
||||||
toast.error("操作失败,请重试!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<CourseEditorContext.Provider
|
|
||||||
value={{
|
|
||||||
onSubmit,
|
|
||||||
editId,
|
|
||||||
// course
|
|
||||||
}}>
|
|
||||||
<FormProvider {...methods}>{children}</FormProvider>
|
|
||||||
</CourseEditorContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCourseEditor = () => {
|
|
||||||
const context = useContext(CourseEditorContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"useCourseEditor must be used within CourseFormProvider"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
|
@ -1,24 +0,0 @@
|
||||||
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
|
||||||
import { CourseLevel, CourseLevelLabel } from '@nice/common';
|
|
||||||
|
|
||||||
import { FormSelect } from '@web/src/components/common/form/FormSelect';
|
|
||||||
import { FormArrayField } from '@web/src/components/common/form/FormArrayField';
|
|
||||||
import { convertToOptions } from '@nice/client';
|
|
||||||
import { CourseFormData } from '../context/CourseEditorContext';
|
|
||||||
import QuillEditor from '@web/src/components/common/editor/quill/QuillEditor';
|
|
||||||
import { FormQuillInput } from '@web/src/components/common/form/FormQuillInput';
|
|
||||||
|
|
||||||
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 viewMode maxLength={20} name="title" label="课程标题" placeholder="请输入课程标题" /> */}
|
|
||||||
{/* <FormInput maxLength={10} name="subTitle" label="课程副标题" placeholder="请输入课程副标题" /> */}
|
|
||||||
<FormQuillInput maxLength={400} name="description" label="课程描述" placeholder="请输入课程描述"></FormQuillInput>
|
|
||||||
|
|
||||||
<FormSelect name='level' label='难度等级' options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,64 +0,0 @@
|
||||||
import { Button } from "@web/src/components/common/element/Button";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { PlusIcon, } from "@heroicons/react/24/outline";
|
|
||||||
import { Section, UUIDGenerator } from "@nice/common";
|
|
||||||
import SectionFormList from "./SectionFormList";
|
|
||||||
|
|
||||||
const CourseContentFormHeader = () =>
|
|
||||||
<div className="mb-8 bg-blue-50 p-4 rounded-lg">
|
|
||||||
<h2 className="text-xl font-semibold text-blue-800 mb-2">创建您的课程大纲</h2>
|
|
||||||
<p className="text-primary-600">
|
|
||||||
通过组织清晰的章节和课时,帮助学员更好地学习。建议:
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 text-primary-600 list-disc list-inside">
|
|
||||||
<li>将相关内容组织到章节中</li>
|
|
||||||
<li>每个章节建议包含 3-7 个课时</li>
|
|
||||||
<li>课时可以是视频、文章或测验</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
const CourseSectionEmpty = () => (
|
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
|
||||||
<div className="text-tertiary-300">
|
|
||||||
<PlusIcon className="mx-auto h-12 w-12 mb-4" />
|
|
||||||
<h3 className="text-lg font-medium mb-2">开始创建您的课程内容</h3>
|
|
||||||
<p className="text-sm">点击下方按钮添加第一个章节</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default function CourseContentForm() {
|
|
||||||
const [sections, setSections] = useState<Section[]>([]);
|
|
||||||
const addSection = () => {
|
|
||||||
setSections(prev => [...prev, {
|
|
||||||
id: UUIDGenerator.generate(),
|
|
||||||
title: '新章节',
|
|
||||||
lectures: []
|
|
||||||
}]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
|
||||||
<CourseContentFormHeader />
|
|
||||||
<div className="space-y-4">
|
|
||||||
{sections.length === 0 ? (
|
|
||||||
<CourseSectionEmpty />
|
|
||||||
) : (
|
|
||||||
<SectionFormList
|
|
||||||
sections={sections}
|
|
||||||
setSections={setSections}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
size="lg"
|
|
||||||
rounded="xl"
|
|
||||||
onClick={addSection}
|
|
||||||
leftIcon={<PlusIcon></PlusIcon>}
|
|
||||||
className="mt-4 bg-blue-500 hover:bg-blue-600 text-white"
|
|
||||||
>
|
|
||||||
添加新章节
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
import { FormArrayField } from "@web/src/components/common/form/FormArrayField";
|
|
||||||
import { useFormContext } from "react-hook-form";
|
|
||||||
import { CourseFormData } from "../context/CourseEditorContext";
|
|
||||||
|
|
||||||
export function CourseGoalForm() {
|
|
||||||
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
|
||||||
<FormArrayField name="requirements" label="前置要求" placeholder="添加要求"></FormArrayField>
|
|
||||||
<FormArrayField name="objectives" label="学习目标" placeholder="添加目标"></FormArrayField>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
export default function CourseSettingForm() {
|
|
||||||
return <>Setting</>
|
|
||||||
}
|
|
|
@ -1,164 +0,0 @@
|
||||||
import { VideoCameraIcon, DocumentTextIcon, QuestionMarkCircleIcon, Bars3Icon, PencilIcon, TrashIcon, CloudArrowUpIcon, CheckIcon, ChevronUpIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { Lecture } from "@nice/common";
|
|
||||||
import { Card } from "@web/src/components/common/container/Card";
|
|
||||||
import { Button } from "@web/src/components/common/element/Button";
|
|
||||||
// import { FormInput } from "@web/src/components/common/form/FormInput";
|
|
||||||
import { FormQuillInput } from "@web/src/components/common/form/FormQuillInput";
|
|
||||||
import FileUploader from "@web/src/components/common/uploader/FileUploader";
|
|
||||||
import { useState } from "react";
|
|
||||||
const LectureTypeIcon = ({ type }: { type: string }) => {
|
|
||||||
const iconClass = "h-5 w-5";
|
|
||||||
switch (type) {
|
|
||||||
case 'video':
|
|
||||||
return <VideoCameraIcon className={`${iconClass} text-blue-500`} />;
|
|
||||||
case 'article':
|
|
||||||
return <DocumentTextIcon className={`${iconClass} text-green-500`} />;
|
|
||||||
case 'quiz':
|
|
||||||
return <QuestionMarkCircleIcon className={`${iconClass} text-purple-500`} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
interface LectureHeaderProps {
|
|
||||||
lecture: Lecture;
|
|
||||||
index: number;
|
|
||||||
isExpanded: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
}
|
|
||||||
export function LectureHeader({
|
|
||||||
lecture,
|
|
||||||
index,
|
|
||||||
isExpanded,
|
|
||||||
onToggle,
|
|
||||||
onDelete
|
|
||||||
}: LectureHeaderProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="group/lecture flex items-center gap-4 justify-between"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<Button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggle();
|
|
||||||
}}
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
title={isExpanded ? "收起课时" : "展开课时"}
|
|
||||||
leftIcon={<ChevronRightIcon
|
|
||||||
className={`transform transition-transform duration-200 ease-in-out
|
|
||||||
${isExpanded ? 'rotate-90' : ''}`}
|
|
||||||
/>}
|
|
||||||
>
|
|
||||||
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 flex-1">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<LectureTypeIcon type={lecture.type} />
|
|
||||||
</div>
|
|
||||||
<span className="inline-flex items-center justify-center w-5 h-5
|
|
||||||
text-xs rounded-full bg-blue-100 text-blue-500">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
<FormInput viewMode name="title"></FormInput>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-1 opacity-0 group-hover/lecture:opacity-100
|
|
||||||
transition-all duration-200 ease-in-out">
|
|
||||||
<Button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete(lecture.id);
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost-danger"
|
|
||||||
leftIcon={<TrashIcon></TrashIcon>}
|
|
||||||
title="删除课时"
|
|
||||||
>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
interface LectureEditorProps {
|
|
||||||
lecture: Lecture;
|
|
||||||
onUpdate: (lecture: Lecture) => void;
|
|
||||||
|
|
||||||
}
|
|
||||||
export function LectureEditor({ lecture, onUpdate }: LectureEditorProps) {
|
|
||||||
const tabs = [
|
|
||||||
{ key: 'video', icon: VideoCameraIcon, label: '视频' },
|
|
||||||
{ key: 'article', icon: DocumentTextIcon, label: '文章' }
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<div className=" pt-6 bg-white" >
|
|
||||||
<div className="flex space-x-4 mb-6 px-6">
|
|
||||||
{tabs.map(({ key, icon: Icon, label }) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => onUpdate({ ...lecture, type: key })}
|
|
||||||
className={`flex items-center space-x-2 px-6 py-2.5 rounded-lg transition-all
|
|
||||||
${lecture.type === key
|
|
||||||
? 'bg-blue-50 text-primary-600 shadow-sm ring-1 ring-blue-100'
|
|
||||||
: 'text-gray-600 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
<span className="font-medium">{label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-6 pb-6 flex flex-col gap-4">
|
|
||||||
{lecture.type === 'video' && (
|
|
||||||
<div className="relative">
|
|
||||||
<FileUploader placeholder="点击或拖拽视频到这里上传" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{lecture.type === 'article' && (
|
|
||||||
<div>
|
|
||||||
<FormQuillInput minRows={8} label="文章内容" name="content" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="relative">
|
|
||||||
<FileUploader placeholder="点击或拖拽资源到这里上传" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LectureFormItemProps {
|
|
||||||
lecture: Lecture;
|
|
||||||
index: number;
|
|
||||||
onUpdate: (lecture: Lecture) => void;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LectureFormItem(props: LectureFormItemProps) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const handleToggle = () => {
|
|
||||||
setIsExpanded(!isExpanded);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
variant="outlined"
|
|
||||||
className="flex-1 group relative flex flex-col p-4 "
|
|
||||||
>
|
|
||||||
<LectureHeader
|
|
||||||
{...props}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
onToggle={handleToggle}
|
|
||||||
/>
|
|
||||||
{isExpanded && (
|
|
||||||
<LectureEditor
|
|
||||||
lecture={props.lecture}
|
|
||||||
onUpdate={props.onUpdate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,145 +0,0 @@
|
||||||
import { Lecture } from "packages/common/dist";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { LectureFormItem } from "./LectureFormItem";
|
|
||||||
import { Bars3Icon } from "@heroicons/react/24/outline";
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
DragEndEvent,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
useSortable,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import { Button } from "@web/src/components/common/element/Button";
|
|
||||||
|
|
||||||
interface LectureFormListProps {
|
|
||||||
lectures: Lecture[];
|
|
||||||
sectionId: string;
|
|
||||||
onUpdate: (lectures: Lecture[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SortableItemProps {
|
|
||||||
lecture: Lecture;
|
|
||||||
index: number;
|
|
||||||
onUpdate: (lecture: Lecture) => void;
|
|
||||||
onDelete: (lectureId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortableItem({ lecture, index, onUpdate, onDelete }: SortableItemProps) {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: lecture.id });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
|
|
||||||
className={`flex items-center relative ${isDragging ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<LectureFormItem
|
|
||||||
lecture={lecture}
|
|
||||||
index={index}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
onDelete={onDelete}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
leftIcon={
|
|
||||||
|
|
||||||
<Bars3Icon
|
|
||||||
/>
|
|
||||||
} variant="ghost" className="absolute -left-8 cursor-grab active:cursor-grabbing " title="拖拽排序"
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
>
|
|
||||||
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LectureFormList({ lectures, sectionId, onUpdate }: LectureFormListProps) {
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 8,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUpdate = useCallback((updatedLecture: Lecture) => {
|
|
||||||
onUpdate(lectures.map(l => l.id === updatedLecture.id ? updatedLecture : l));
|
|
||||||
}, [lectures, onUpdate]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback((lectureId: string) => {
|
|
||||||
onUpdate(lectures.filter(l => l.id !== lectureId));
|
|
||||||
}, [lectures, onUpdate]);
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (over && active.id !== over.id) {
|
|
||||||
const oldIndex = lectures.findIndex((lecture) => lecture.id === active.id);
|
|
||||||
const newIndex = lectures.findIndex((lecture) => lecture.id === over.id);
|
|
||||||
const newLectures = [...lectures];
|
|
||||||
const [removed] = newLectures.splice(oldIndex, 1);
|
|
||||||
newLectures.splice(newIndex, 0, removed);
|
|
||||||
onUpdate(newLectures);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (lectures.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="select-none flex items-center justify-center text-tertiary-300">
|
|
||||||
暂无课程内容
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={lectures.map(lecture => lecture.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{lectures.map((lecture, index) => (
|
|
||||||
<SortableItem
|
|
||||||
key={lecture.id}
|
|
||||||
lecture={lecture}
|
|
||||||
index={index}
|
|
||||||
onUpdate={handleUpdate}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,110 +0,0 @@
|
||||||
import {
|
|
||||||
TrashIcon,
|
|
||||||
PlusIcon,
|
|
||||||
ChevronDownIcon
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { Section, Lecture } from "@nice/common";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { LectureFormList } from "./LectureFormList";
|
|
||||||
import { cn } from "@web/src/utils/classname";
|
|
||||||
// import { FormInput } from "@web/src/components/common/form/FormInput";
|
|
||||||
import { Button } from "@web/src/components/common/element/Button";
|
|
||||||
import { Card } from "@web/src/components/common/container/Card";
|
|
||||||
interface SectionProps {
|
|
||||||
section: Section;
|
|
||||||
index: number;
|
|
||||||
onUpdate: (section: Section) => void;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SectionFormItem({ section, index, onUpdate, onDelete }: SectionProps) {
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
||||||
const handleAddLecture = () => {
|
|
||||||
const newLecture: Lecture = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
title: '新课时',
|
|
||||||
type: 'video'
|
|
||||||
};
|
|
||||||
onUpdate({
|
|
||||||
...section,
|
|
||||||
lectures: [...section.lectures, newLecture]
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="group/section relative flex-1 p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
||||||
title={isCollapsed ? "展开章节" : "收起章节"}
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
leftIcon={<ChevronDownIcon
|
|
||||||
className={cn(
|
|
||||||
"transition-transform duration-300 ease-out",
|
|
||||||
isCollapsed ? "-rotate-90" : "rotate-0"
|
|
||||||
)}
|
|
||||||
/>}
|
|
||||||
>
|
|
||||||
|
|
||||||
</Button>
|
|
||||||
<span className="inline-flex items-center justify-center w-7 h-7
|
|
||||||
bg-blue-50 text-primary-600 text-sm font-semibold rounded">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
{/* <FormInput viewMode name="title"></FormInput> */}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 opacity-0 group-hover/section:opacity-100
|
|
||||||
transition-all duration-200 ease-in-out">
|
|
||||||
<Button
|
|
||||||
onClick={() => onDelete(section.id)}
|
|
||||||
variant="ghost-danger"
|
|
||||||
size="sm"
|
|
||||||
aria-label="删除章节"
|
|
||||||
title="删除章节"
|
|
||||||
leftIcon={<TrashIcon />}
|
|
||||||
>
|
|
||||||
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"grid transition-all duration-300 ease-out",
|
|
||||||
isCollapsed
|
|
||||||
? "grid-rows-[0fr] opacity-0 invisible"
|
|
||||||
: "grid-rows-[1fr] opacity-100 visible"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn(
|
|
||||||
"overflow-hidden transition-all duration-300",
|
|
||||||
isCollapsed ? "hidden" : "px-10 py-4"
|
|
||||||
)}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<LectureFormList
|
|
||||||
lectures={section.lectures}
|
|
||||||
sectionId={section.id}
|
|
||||||
onUpdate={(updatedLectures) =>
|
|
||||||
onUpdate({ ...section, lectures: updatedLectures })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleAddLecture}
|
|
||||||
size="md"
|
|
||||||
fullWidth
|
|
||||||
variant="soft-primary"
|
|
||||||
leftIcon={<PlusIcon></PlusIcon>}
|
|
||||||
>
|
|
||||||
|
|
||||||
<span className="font-medium">添加课时</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,141 +0,0 @@
|
||||||
import { Section } from "@nice/common";
|
|
||||||
import { SectionFormItem } from "./SectionFormItem";
|
|
||||||
import { Bars3Icon } from "@heroicons/react/24/outline";
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
DragEndEvent,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
useSortable,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import { Button } from "@web/src/components/common/element/Button";
|
|
||||||
|
|
||||||
interface SectionFormListProps {
|
|
||||||
sections: Section[];
|
|
||||||
setSections: React.Dispatch<React.SetStateAction<Section[]>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SortableItemProps {
|
|
||||||
section: Section;
|
|
||||||
index: number;
|
|
||||||
onUpdate: (section: Section) => void;
|
|
||||||
onDelete: (sectionId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortableItem({ section, index, onUpdate, onDelete }: SortableItemProps) {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: section.id });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={`flex items-center relative ${isDragging ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<SectionFormItem
|
|
||||||
key={section.id}
|
|
||||||
section={section}
|
|
||||||
index={index}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
onDelete={onDelete}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
leftIcon={
|
|
||||||
|
|
||||||
<Bars3Icon
|
|
||||||
/>
|
|
||||||
} variant="ghost" className="absolute -right-10 cursor-grab active:cursor-grabbing " title="拖拽排序"
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
></Button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SectionFormList({ sections, setSections }: SectionFormListProps) {
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 8, // 8px的移动距离后才开始拖拽
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSection = (updatedSection: Section) => {
|
|
||||||
setSections(prev => prev.map(section =>
|
|
||||||
section.id === updatedSection.id ? updatedSection : section
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteSection = (sectionId: string) => {
|
|
||||||
setSections(prev => prev.filter(section => section.id !== sectionId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
|
||||||
setSections((prev) => {
|
|
||||||
const oldIndex = prev.findIndex((section) => section.id === active.id);
|
|
||||||
const newIndex = prev.findIndex((section) => section.id === over.id);
|
|
||||||
|
|
||||||
const newSections = [...prev];
|
|
||||||
const [removed] = newSections.splice(oldIndex, 1);
|
|
||||||
newSections.splice(newIndex, 0, removed);
|
|
||||||
|
|
||||||
return newSections;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={sections.map(section => section.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{sections.map((section, index) => (
|
|
||||||
<SortableItem
|
|
||||||
key={section.id}
|
|
||||||
section={section}
|
|
||||||
index={index}
|
|
||||||
onUpdate={updateSection}
|
|
||||||
onDelete={deleteSection}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
import { ArrowLeftIcon, ClockIcon } from '@heroicons/react/24/outline';
|
|
||||||
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Button } from '@web/src/components/common/element/Button';
|
|
||||||
import { CourseStatus, CourseStatusLabel } from '@nice/common';
|
|
||||||
import Tag from '@web/src/components/common/element/Tag';
|
|
||||||
import { CourseFormData, useCourseEditor } from '../context/CourseEditorContext';
|
|
||||||
const courseStatusVariant: Record<CourseStatus, string> = {
|
|
||||||
[CourseStatus.DRAFT]: 'default',
|
|
||||||
[CourseStatus.UNDER_REVIEW]: 'warning',
|
|
||||||
[CourseStatus.PUBLISHED]: 'success',
|
|
||||||
[CourseStatus.ARCHIVED]: 'danger'
|
|
||||||
};
|
|
||||||
export default function CourseEditorHeader() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext<CourseFormData>()
|
|
||||||
|
|
||||||
const { onSubmit, course } = useCourseEditor()
|
|
||||||
return (
|
|
||||||
<header className="fixed top-0 left-0 right-0 h-16 bg-white border-b border-gray-200 z-10">
|
|
||||||
<div className="h-full flex items-center justify-between px-3 md:px-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-5 h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<h2 className="font-medium text-gray-900">{course?.title || '新建课程'}</h2>
|
|
||||||
<Tag variant={courseStatusVariant[course?.status || CourseStatus.DRAFT]}>
|
|
||||||
{course?.status ? CourseStatusLabel[course.status] : CourseStatusLabel[CourseStatus.DRAFT]}
|
|
||||||
</Tag>
|
|
||||||
{course?.totalDuration ? (
|
|
||||||
<div className="hidden md:flex items-center text-tertiary-300 text-sm">
|
|
||||||
<ClockIcon className="w-4 h-4 mr-1" />
|
|
||||||
<span>总时长 {course?.totalDuration}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
disabled={course ? (!isValid || !isDirty) : !isValid}
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSubmit(onSubmit)}
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,64 +0,0 @@
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
|
||||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import CourseEditorHeader from "./CourseEditorHeader";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { NavItem } from "@nice/client"
|
|
||||||
import CourseEditorSidebar from "./CourseEditorSidebar";
|
|
||||||
import { CourseFormProvider } from "../context/CourseEditorContext";
|
|
||||||
import { getNavItems } from "../navItems";
|
|
||||||
|
|
||||||
|
|
||||||
export default function CourseEditorLayout() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const [selectedSection, setSelectedSection] = useState<number>(0);
|
|
||||||
const [navItems, setNavItems] = useState<NavItem[]>(getNavItems(id));
|
|
||||||
useEffect(() => {
|
|
||||||
setNavItems(getNavItems(id))
|
|
||||||
}, [id])
|
|
||||||
useEffect(() => {
|
|
||||||
const currentPath = location.pathname;
|
|
||||||
const index = navItems.findIndex(item => item.path === currentPath);
|
|
||||||
if (index !== -1) {
|
|
||||||
setSelectedSection(index);
|
|
||||||
}
|
|
||||||
}, [location.pathname, navItems]);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const handleNavigation = (item: NavItem, index: number) => {
|
|
||||||
setSelectedSection(index);
|
|
||||||
navigate(item.path, { replace: true });
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<CourseFormProvider editId={id}>
|
|
||||||
<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">
|
|
||||||
<Outlet></Outlet>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CourseFormProvider>
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,55 +0,0 @@
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { NavItem } from "@nice/client"
|
|
||||||
interface CourseSidebarProps {
|
|
||||||
isHovered: boolean;
|
|
||||||
setIsHovered: (value: boolean) => void;
|
|
||||||
navItems: NavItem[];
|
|
||||||
selectedSection: number;
|
|
||||||
onNavigate: (item: NavItem, index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CourseEditorSidebar({
|
|
||||||
isHovered,
|
|
||||||
setIsHovered,
|
|
||||||
navItems,
|
|
||||||
selectedSection,
|
|
||||||
onNavigate
|
|
||||||
}: CourseSidebarProps) {
|
|
||||||
return (
|
|
||||||
<motion.nav
|
|
||||||
initial={{ width: "5rem" }}
|
|
||||||
animate={{ width: isHovered ? "16rem" : "5rem" }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 40 }}
|
|
||||||
onHoverStart={() => setIsHovered(true)}
|
|
||||||
onHoverEnd={() => setIsHovered(false)}
|
|
||||||
className="fixed left-0 top-16 h-[calc(100vh-4rem)] bg-white border-r border-gray-200 shadow-lg overflow-x-hidden"
|
|
||||||
>
|
|
||||||
<div className="p-4">
|
|
||||||
{navItems.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
onClick={() => onNavigate(item, index)}
|
|
||||||
className={`w-full flex ${!isHovered ? 'justify-center' : 'items-center'} px-5 py-2.5 mb-1 rounded-lg transition-all duration-200 ${selectedSection === index
|
|
||||||
? "bg-blue-50 text-primary-600 shadow-sm"
|
|
||||||
: "text-gray-600 hover:bg-gray-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex-shrink-0">{item.icon}</span>
|
|
||||||
{isHovered && (
|
|
||||||
<>
|
|
||||||
<motion.span
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
className="ml-3 font-medium flex-1 truncate"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</motion.span>
|
|
||||||
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.nav>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,47 +0,0 @@
|
||||||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
|
||||||
|
|
||||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
|
||||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
|
||||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
|
||||||
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
|
||||||
import { convertToOptions } from "@nice/client";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export function CourseBasicForm() {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
formState: { errors },
|
|
||||||
watch,
|
|
||||||
handleSubmit,
|
|
||||||
} = useFormContext<CourseFormData>();
|
|
||||||
// useEffect(() => {
|
|
||||||
// console.log(watch("audiences"));
|
|
||||||
// }, [watch("audiences")]);
|
|
||||||
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,47 +0,0 @@
|
||||||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
|
||||||
|
|
||||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
|
||||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
|
||||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
|
||||||
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
|
||||||
import { convertToOptions } from "@nice/client";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export function CourseContentForm() {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
formState: { errors },
|
|
||||||
watch,
|
|
||||||
handleSubmit,
|
|
||||||
} = useFormContext<CourseFormData>();
|
|
||||||
// useEffect(() => {
|
|
||||||
// console.log(watch("audiences"));
|
|
||||||
// }, [watch("audiences")]);
|
|
||||||
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,23 +0,0 @@
|
||||||
import { useContext } from "react";
|
|
||||||
import { useCourseEditor } from "../../context/CourseEditorContext";
|
|
||||||
import { CoursePart } from "../enum";
|
|
||||||
import { CourseBasicForm } from "./CourseBasicForm";
|
|
||||||
import { CourseTargetForm } from "./CourseTargetForm";
|
|
||||||
import { CourseContentForm } from "./CourseContentForm";
|
|
||||||
|
|
||||||
export default function CourseForm() {
|
|
||||||
const { part } = useCourseEditor();
|
|
||||||
if (part === CoursePart.OVERVIEW) {
|
|
||||||
return <CourseBasicForm></CourseBasicForm>;
|
|
||||||
}
|
|
||||||
if (part === CoursePart.TARGET) {
|
|
||||||
return <CourseTargetForm></CourseTargetForm>;
|
|
||||||
}
|
|
||||||
if (part === CoursePart.CONTENT) {
|
|
||||||
return <CourseContentForm></CourseContentForm>;
|
|
||||||
}
|
|
||||||
if (part === CoursePart.SETTING) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
return <CourseBasicForm></CourseBasicForm>;
|
|
||||||
}
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
|
||||||
|
|
||||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
|
||||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
|
||||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
|
||||||
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
|
||||||
import { convertToOptions } from "@nice/client";
|
|
||||||
|
|
||||||
export function CourseContentForm() {
|
|
||||||
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,43 +0,0 @@
|
||||||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
|
||||||
|
|
||||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
|
||||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
|
||||||
import { convertToOptions } from "@nice/client";
|
|
||||||
import { FormDynamicInputs } from "@web/src/components/common/form/FormDynamicInputs";
|
|
||||||
|
|
||||||
export function CourseTargetForm() {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
formState: { errors },
|
|
||||||
watch,
|
|
||||||
handleSubmit,
|
|
||||||
} = useFormContext<CourseFormData>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
|
||||||
<FormDynamicInputs
|
|
||||||
name="objectives"
|
|
||||||
label="本课的具体学习目标是什么?"
|
|
||||||
// subTitle="学员在完成您的课程后期望掌握的技能"
|
|
||||||
addTitle="目标"></FormDynamicInputs>
|
|
||||||
<FormDynamicInputs
|
|
||||||
name="skills"
|
|
||||||
label="学生将从您的课程中学到什么技能?"
|
|
||||||
subTitle="学员在完成您的课程后期望掌握的技能"
|
|
||||||
addTitle="技能"></FormDynamicInputs>
|
|
||||||
<FormDynamicInputs
|
|
||||||
name="requirements"
|
|
||||||
label="参加课程的要求或基本要求是什么?"
|
|
||||||
subTitle="列出学员在参加课程之前应具备的所需技能、经验、工具或设备。
|
|
||||||
如果没有要求,则可利用此空间作为降低初学者门槛的机会。"
|
|
||||||
addTitle="要求"></FormDynamicInputs>
|
|
||||||
<FormDynamicInputs
|
|
||||||
name="audiences"
|
|
||||||
subTitle="撰写您的课程目标学员的清晰描述,让学员了解您的课程内容很有价值。这将帮助您吸引合适的学员加入您的课程。"
|
|
||||||
addTitle="目标受众"
|
|
||||||
label="此课程的受众是谁?"></FormDynamicInputs>
|
|
||||||
|
|
||||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
export enum CoursePart {
|
|
||||||
OVERVIEW = "overview",
|
|
||||||
TARGET = "target",
|
|
||||||
CONTENT = "content",
|
|
||||||
SETTING = "settings",
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
import { AcademicCapIcon, BookOpenIcon, Cog6ToothIcon, VideoCameraIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { NavItem } from "@nice/client";
|
|
||||||
|
|
||||||
export const getNavItems = (courseId?: string): (NavItem & { isCompleted?: boolean })[] => [
|
|
||||||
{
|
|
||||||
label: "课程概述",
|
|
||||||
icon: <BookOpenIcon className="w-5 h-5" />,
|
|
||||||
path: `/course/${courseId ? `${courseId}/` : ''}editor`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "目标学员",
|
|
||||||
icon: <AcademicCapIcon className="w-5 h-5" />,
|
|
||||||
path: `/course/${courseId ? `${courseId}/` : ''}editor/goal`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "课程内容",
|
|
||||||
icon: <VideoCameraIcon className="w-5 h-5" />,
|
|
||||||
path: `/course/${courseId ? `${courseId}/` : ''}editor/content`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "课程设置",
|
|
||||||
icon: <Cog6ToothIcon className="w-5 h-5" />,
|
|
||||||
path: `/course/${courseId ? `${courseId}/` : ''}editor/setting`
|
|
||||||
},
|
|
||||||
];
|
|
|
@ -1,61 +0,0 @@
|
||||||
// CourseList.tsx
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { Course, CourseDto } from "@nice/common";
|
|
||||||
import { EmptyState } from "@web/src/components/common/space/Empty";
|
|
||||||
import { Pagination } from "@web/src/components/common/element/Pagination";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface CourseListProps {
|
|
||||||
courses?: CourseDto[];
|
|
||||||
renderItem: (course: CourseDto) => React.ReactNode;
|
|
||||||
emptyComponent?: React.ReactNode;
|
|
||||||
// 新增分页相关属性
|
|
||||||
currentPage: number;
|
|
||||||
totalPages: number;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
show: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.05,
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
export const CourseList = ({
|
|
||||||
courses,
|
|
||||||
renderItem,
|
|
||||||
emptyComponent: EmptyComponent,
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
onPageChange,
|
|
||||||
}: CourseListProps) => {
|
|
||||||
if (!courses || courses.length === 0) {
|
|
||||||
return EmptyComponent || <EmptyState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<motion.div
|
|
||||||
variants={container}
|
|
||||||
initial="hidden"
|
|
||||||
animate="show"
|
|
||||||
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
||||||
{courses.map((course) => (
|
|
||||||
<motion.div key={course.id}>
|
|
||||||
{renderItem(course)}
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -13,20 +13,12 @@ import RoleAdminPage from "../app/admin/role/page";
|
||||||
import WithAuth from "../components/utils/with-auth";
|
import WithAuth from "../components/utils/with-auth";
|
||||||
import BaseSettingPage from "../app/admin/base-setting/page";
|
import BaseSettingPage from "../app/admin/base-setting/page";
|
||||||
import { MainLayout } from "../components/layout/main/MainLayout";
|
import { MainLayout } from "../components/layout/main/MainLayout";
|
||||||
import HomePage from "../app/main/home/page";
|
|
||||||
import { CourseBasicForm } from "../components/models/course/editor/form/CourseBasicForm";
|
|
||||||
import CourseContentForm from "../components/models/course/editor/form/CourseContentForm";
|
|
||||||
import { CourseGoalForm } from "../components/models/course/editor/form/CourseGoalForm";
|
|
||||||
import CourseSettingForm from "../components/models/course/editor/form/CourseSettingForm";
|
|
||||||
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
|
|
||||||
import WriteLetterPage from "../app/main/letter/write/page";
|
import WriteLetterPage from "../app/main/letter/write/page";
|
||||||
import LetterListPage from "../app/main/letter/list/page";
|
import LetterListPage from "../app/main/letter/list/page";
|
||||||
import LetterProgressPage from "../app/main/letter/progress/page";
|
import LetterProgressPage from "../app/main/letter/progress/page";
|
||||||
import HelpPage from "../app/main/help/page";
|
import HelpPage from "../app/main/help/page";
|
||||||
import AuthPage from "../app/auth/page";
|
import AuthPage from "../app/auth/page";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import LetterEditorLayout from "../components/models/post/LetterEditor/layout/LetterEditorLayout";
|
|
||||||
import { LetterBasicForm } from "../components/models/post/LetterEditor/form/LetterBasicForm";
|
|
||||||
import EditorLetterPage from "../app/main/letter/editor/page";
|
import EditorLetterPage from "../app/main/letter/editor/page";
|
||||||
import LetterDetailPage from "../app/main/letter/detail/page";
|
import LetterDetailPage from "../app/main/letter/detail/page";
|
||||||
|
|
||||||
|
@ -69,7 +61,7 @@ export const routes: CustomRouteObject[] = [
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
element: <HomePage />,
|
element: <LetterListPage></LetterListPage>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ":id?/detail",
|
path: ":id?/detail",
|
||||||
|
@ -83,10 +75,7 @@ export const routes: CustomRouteObject[] = [
|
||||||
path: "write-letter",
|
path: "write-letter",
|
||||||
element: <WriteLetterPage></WriteLetterPage>,
|
element: <WriteLetterPage></WriteLetterPage>,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "letter-list",
|
|
||||||
element: <LetterListPage></LetterListPage>
|
|
||||||
}
|
|
||||||
, {
|
, {
|
||||||
path: 'letter-progress',
|
path: 'letter-progress',
|
||||||
element: <LetterProgressPage></LetterProgressPage>
|
element: <LetterProgressPage></LetterProgressPage>
|
||||||
|
@ -97,38 +86,6 @@ export const routes: CustomRouteObject[] = [
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
path: "course",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: ":id?/editor",
|
|
||||||
element: <CourseEditorLayout></CourseEditorLayout>,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
index: true,
|
|
||||||
element: <CourseBasicForm></CourseBasicForm>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "goal",
|
|
||||||
element: <CourseGoalForm></CourseGoalForm>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "content",
|
|
||||||
element: (
|
|
||||||
<CourseContentForm></CourseContentForm>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "setting",
|
|
||||||
element: (
|
|
||||||
<CourseSettingForm></CourseSettingForm>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "admin",
|
path: "admin",
|
||||||
children: [
|
children: [
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
import { type ClassValue, clsx } from 'clsx';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
|
@ -8,8 +8,7 @@ export enum PostType {
|
||||||
}
|
}
|
||||||
export enum TaxonomySlug {
|
export enum TaxonomySlug {
|
||||||
CATEGORY = "category",
|
CATEGORY = "category",
|
||||||
UNIT = "unit",
|
TAG = "tag"
|
||||||
TAG = "tag",
|
|
||||||
}
|
}
|
||||||
export enum VisitType {
|
export enum VisitType {
|
||||||
STAR = "star",
|
STAR = "star",
|
||||||
|
|
|
@ -9,7 +9,7 @@ export function generateColorScale(baseColor: string): ColorScale {
|
||||||
const scale = Object.fromEntries(
|
const scale = Object.fromEntries(
|
||||||
keys.map((key, index) => [
|
keys.map((key, index) => [
|
||||||
key,
|
key,
|
||||||
color.lighten(-steps[index]).hex()
|
color.lighten(-steps[index]).rgb().string()
|
||||||
])
|
])
|
||||||
) as ColorScale;
|
) as ColorScale;
|
||||||
|
|
||||||
|
|
|
@ -18,12 +18,13 @@ export function themeToCssVariables(theme: Theme): Record<string, string> {
|
||||||
const flattenedToken = flattenObject(theme.token)
|
const flattenedToken = flattenObject(theme.token)
|
||||||
console.log(flattenedToken)
|
console.log(flattenedToken)
|
||||||
const cssVars: Record<string, string> = {}
|
const cssVars: Record<string, string> = {}
|
||||||
for (const [path, value] of Object.entries(flattenedToken)) {
|
|
||||||
|
|
||||||
|
for (const [path, value] of Object.entries(flattenedToken)) {
|
||||||
const cssVarName = createCssVariableName(path.split('.'))
|
const cssVarName = createCssVariableName(path.split('.'))
|
||||||
|
|
||||||
cssVars[cssVarName] = value
|
cssVars[cssVarName] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
return cssVars
|
return cssVars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue