174 lines
7.3 KiB
TypeScript
174 lines
7.3 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { BookOpen, Download, Loader2, Plus, Sparkles } from 'lucide-react';
|
||
import { useLocation, useNavigate } from 'react-router-dom';
|
||
import {
|
||
downloadLearningCourse,
|
||
fetchMyLearningCourses,
|
||
LEARNING_LIBRARY_CHANGED_EVENT,
|
||
listInstalledLearningCourses,
|
||
} from '@/lib/learning';
|
||
import { cn } from '@/lib/utils';
|
||
import type { InstalledLearningCourse, LearningCourse } from '../../../shared/learning';
|
||
|
||
type LearningSidebarProps = {
|
||
sidebarCollapsed: boolean;
|
||
};
|
||
|
||
export function LearningSidebar({ sidebarCollapsed }: LearningSidebarProps) {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const [installed, setInstalled] = useState<Record<string, InstalledLearningCourse>>({});
|
||
const [ownedCourses, setOwnedCourses] = useState<LearningCourse[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [downloading, setDownloading] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const loadCourses = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [localCourses, personalCourses] = await Promise.all([
|
||
listInstalledLearningCourses(),
|
||
fetchMyLearningCourses().catch(() => []),
|
||
]);
|
||
setInstalled(Object.fromEntries(localCourses.map((record) => [record.course.id, record])));
|
||
setOwnedCourses(personalCourses);
|
||
} catch (cause) {
|
||
setError(cause instanceof Error ? cause.message : '个人课程暂时无法读取');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void loadCourses();
|
||
const refresh = () => void loadCourses();
|
||
window.addEventListener(LEARNING_LIBRARY_CHANGED_EVENT, refresh);
|
||
return () => window.removeEventListener(LEARNING_LIBRARY_CHANGED_EVENT, refresh);
|
||
}, [loadCourses]);
|
||
|
||
const personalCourses = (() => {
|
||
const courses = new Map<string, LearningCourse>();
|
||
for (const record of Object.values(installed)) courses.set(record.course.id, record.course);
|
||
for (const course of ownedCourses) courses.set(course.id, course);
|
||
return [...courses.values()];
|
||
})();
|
||
|
||
const openCourse = useCallback(async (course: LearningCourse) => {
|
||
if (installed[course.id]) {
|
||
navigate(`/learning/course/${encodeURIComponent(course.id)}`);
|
||
return;
|
||
}
|
||
setDownloading(course.id);
|
||
setError(null);
|
||
try {
|
||
const record = await downloadLearningCourse(course.id);
|
||
setInstalled((current) => ({ ...current, [course.id]: record }));
|
||
navigate(`/learning/course/${encodeURIComponent(course.id)}`);
|
||
} catch (cause) {
|
||
setError(cause instanceof Error ? cause.message : '课程下载失败');
|
||
} finally {
|
||
setDownloading(null);
|
||
}
|
||
}, [installed, navigate]);
|
||
|
||
const catalogActive = location.pathname === '/learning';
|
||
|
||
return (
|
||
<div data-testid="sidebar-learning-navigation" className="px-1 py-1">
|
||
<button
|
||
type="button"
|
||
aria-label="课程广场"
|
||
aria-current={catalogActive ? 'page' : undefined}
|
||
onClick={() => navigate('/learning')}
|
||
className={cn(
|
||
'motion-press flex min-h-9 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-semibold transition-colors active:scale-[0.985]',
|
||
catalogActive ? 'bg-brand-soft text-foreground' : 'text-foreground hover:bg-surface-subtle',
|
||
sidebarCollapsed && 'justify-center px-0',
|
||
)}
|
||
>
|
||
<BookOpen className="h-4 w-4 shrink-0 text-brand" />
|
||
{!sidebarCollapsed ? <span>课程广场</span> : null}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
aria-label="生成课程"
|
||
onClick={() => navigate('/learning?generate=1')}
|
||
className={cn(
|
||
'motion-press mt-1 flex min-h-9 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-surface-subtle active:scale-[0.985]',
|
||
sidebarCollapsed && 'justify-center px-0',
|
||
)}
|
||
>
|
||
<Plus className="h-4 w-4 shrink-0 text-brand" />
|
||
{!sidebarCollapsed ? <span>生成课程</span> : null}
|
||
</button>
|
||
|
||
{!sidebarCollapsed ? (
|
||
<div className="mt-4">
|
||
<div className="flex min-h-8 items-center gap-2 px-2 text-xs font-semibold text-muted-foreground">
|
||
<Sparkles className="h-4 w-4 shrink-0" />
|
||
<span className="min-w-0 flex-1 truncate">我的课程</span>
|
||
{!loading ? <span className="tabular-nums">{personalCourses.length}</span> : null}
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div role="status" className="flex items-center px-2 py-3 text-xs font-medium text-muted-foreground">
|
||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />正在读取课程
|
||
</div>
|
||
) : error ? (
|
||
<button
|
||
type="button"
|
||
className="w-full rounded-lg px-2 py-3 text-left text-xs font-medium leading-5 text-destructive hover:bg-destructive/5"
|
||
onClick={() => void loadCourses()}
|
||
>
|
||
{error},点击重试
|
||
</button>
|
||
) : personalCourses.length === 0 ? (
|
||
<p className="px-2 py-3 text-xs font-medium leading-5 text-muted-foreground">
|
||
下载或生成课程后,会显示在这里。
|
||
</p>
|
||
) : (
|
||
<div className="mt-1 space-y-1">
|
||
{personalCourses.map((course) => {
|
||
const isInstalled = Boolean(installed[course.id]);
|
||
const isActive = location.pathname === `/learning/course/${encodeURIComponent(course.id)}`;
|
||
const courseMeta = course.origin === 'user_single'
|
||
? isInstalled ? '我生成的,已下载' : '我生成的,待下载'
|
||
: '已下载到本机';
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={course.id}
|
||
aria-label={`${isInstalled ? '打开' : '下载'}课程 ${course.title}`}
|
||
aria-current={isActive ? 'page' : undefined}
|
||
disabled={downloading === course.id}
|
||
onClick={() => void openCourse(course)}
|
||
className={cn(
|
||
'motion-press group/course flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left transition-[background-color,color,transform] active:scale-[0.985]',
|
||
isActive ? 'bg-brand-selected text-foreground ring-1 ring-brand/20' : 'hover:bg-surface-tertiary',
|
||
)}
|
||
>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-sm font-semibold text-foreground">{course.title}</span>
|
||
<span className="mt-0.5 block truncate text-[11px] font-medium text-muted-foreground">{courseMeta}</span>
|
||
</span>
|
||
{downloading === course.id ? (
|
||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-brand" />
|
||
) : isInstalled ? (
|
||
<BookOpen className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
) : (
|
||
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|