Files
makelore/src/components/layout/LearningSidebar.tsx
inman 01bee3188b
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: integrate learning module
2026-08-16 20:52:16 +08:00

174 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}