refactor(web): split EducationView into education-view module

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-06 22:11:13 +01:00
parent c6c254bb05
commit 6833df9dc5
10 changed files with 299 additions and 171 deletions

View file

@ -1,21 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react';
import { EducationView } from './EducationView';
import { EducationView, EducationViewSkeleton } from './education-view';
const meta: Meta<typeof EducationView> = {
title: 'Components/Features/Views/EducationView',
component: EducationView,
parameters: { layout: 'fullscreen' },
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
),
],
title: 'Components/Features/Views/EducationView',
component: EducationView,
parameters: { layout: 'fullscreen' },
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="bg-kodo-background min-h-layout-page p-4">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = { name: 'Par défaut' };
export const Default: Story = {
name: 'Par défaut',
};
export const Loading: Story = {
render: () => <EducationViewSkeleton />,
name: 'Chargement',
};
export const Empty: Story = {
args: {
initialCourses: [],
},
name: 'Vide',
};

View file

@ -1,158 +1 @@
import React, { useState, useEffect } from 'react';
import { Button } from '../ui/button';
import { SearchInput } from '../ui/input';
import { Course } from '../../types';
import { CourseCard } from '../education/CourseCard';
import { Filter, BookOpen, GraduationCap, Loader2 } from 'lucide-react';
import { educationService } from '../../services/educationService';
import { logger } from '@/utils/logger';
interface EducationViewProps {
onCourseClick?: (course: Course) => void;
onMyCoursesClick?: () => void;
}
export const EducationView: React.FC<EducationViewProps> = ({
onCourseClick,
onMyCoursesClick,
}) => {
const [search, setSearch] = useState('');
const [filterLevel, setFilterLevel] = useState<string>('All');
const [filterPrice, setFilterPrice] = useState<string>('All');
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadCourses = async () => {
setLoading(true);
try {
const data = await educationService.getCatalog();
setCourses(data);
} catch (e) {
logger.error('Failed to load courses', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
};
loadCourses();
}, []);
const filtered = courses.filter((c) => {
const matchSearch =
c.title.toLowerCase().includes(search.toLowerCase()) ||
c.tags?.some((t) => t.toLowerCase().includes(search.toLowerCase()));
const matchLevel = filterLevel === 'All' || c.level === filterLevel;
const matchPrice =
filterPrice === 'All' ||
(filterPrice === 'Free' ? c.price === 0 : (c.price || 0) > 0);
return matchSearch && matchLevel && matchPrice;
});
return (
<div className="space-y-8 animate-fadeIn pb-20">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
<div>
<h2 className="text-2xl font-display font-bold text-white mb-2">
ACADEMY
</h2>
<p className="text-kodo-content-dim font-mono text-sm">
Level up your skills. Earn certificates.
</p>
</div>
<Button
variant="glass"
icon={<GraduationCap className="w-4 h-4" />}
onClick={onMyCoursesClick}
>
MY LEARNING
</Button>
</div>
{/* Filters & Search */}
<div className="flex flex-col md:flex-row gap-4 items-center bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50">
<div className="w-full md:w-96">
<SearchInput
placeholder="Search for courses, skills, or teachers..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<div className="flex items-center gap-2 bg-kodo-void rounded-lg p-1 border border-kodo-steel">
<Filter className="w-4 h-4 text-kodo-content-dim ml-2" />
<select
className="bg-transparent text-sm text-kodo-text-main focus:outline-none p-1 cursor-pointer"
value={filterLevel}
onChange={(e) => setFilterLevel(e.target.value)}
>
<option value="All">All Levels</option>
<option value="Beginner">Beginner</option>
<option value="Intermediate">Intermediate</option>
<option value="Advanced">Advanced</option>
</select>
</div>
<div className="flex items-center gap-2 bg-kodo-void rounded-lg p-1 border border-kodo-steel">
<DollarSignIcon className="w-4 h-4 text-kodo-content-dim ml-2" />
<select
className="bg-transparent text-sm text-kodo-text-main focus:outline-none p-1 cursor-pointer"
value={filterPrice}
onChange={(e) => setFilterPrice(e.target.value)}
>
<option value="All">All Prices</option>
<option value="Free">Free</option>
<option value="Paid">Paid</option>
</select>
</div>
</div>
</div>
{/* Course Grid */}
{loading ? (
<div className="flex justify-center py-24">
<Loader2 className="w-10 h-10 text-kodo-steel animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{filtered.map((course) => (
<CourseCard
key={course.id}
course={course}
onClick={(c) => onCourseClick && onCourseClick(c)}
/>
))}
{filtered.length === 0 && (
<div className="col-span-full text-center py-24 text-kodo-content-dim">
<BookOpen className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No courses found matching your criteria.</p>
</div>
)}
</div>
)}
</div>
);
};
// Helper Icon for local usage if needed, otherwise rely on lucide import
const DollarSignIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<line x1="12" x2="12" y1="2" y2="22" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
);
export { EducationView } from './education-view';

View file

@ -0,0 +1,53 @@
import React from 'react';
import { CourseCard } from '@/components/education/CourseCard';
import { EducationViewHeader } from './EducationViewHeader';
import { EducationViewFilters } from './EducationViewFilters';
import { EducationViewEmpty } from './EducationViewEmpty';
import { EducationViewSkeleton } from './EducationViewSkeleton';
import { useEducationView } from './useEducationView';
import type { EducationViewProps } from './types';
export function EducationView({
onCourseClick,
onMyCoursesClick,
initialCourses,
}: EducationViewProps) {
const {
search,
setSearch,
filterLevel,
setFilterLevel,
filterPrice,
setFilterPrice,
loading,
filtered,
} = useEducationView(initialCourses);
if (loading) {
return <EducationViewSkeleton />;
}
return (
<div className="space-y-8 animate-fadeIn pb-20 min-h-layout-page">
<EducationViewHeader onMyCoursesClick={onMyCoursesClick} />
<EducationViewFilters
search={search}
onSearchChange={setSearch}
filterLevel={filterLevel}
onFilterLevelChange={setFilterLevel}
filterPrice={filterPrice}
onFilterPriceChange={setFilterPrice}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{filtered.map((course) => (
<CourseCard
key={course.id}
course={course}
onClick={(c) => onCourseClick?.(c)}
/>
))}
{filtered.length === 0 && <EducationViewEmpty />}
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
import React from 'react';
import { BookOpen } from 'lucide-react';
export function EducationViewEmpty() {
return (
<div className="col-span-full text-center py-24 text-kodo-content-dim">
<BookOpen className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No courses found matching your criteria.</p>
</div>
);
}

View file

@ -0,0 +1,82 @@
import React from 'react';
import { SearchInput } from '@/components/ui/input';
import { Filter } from 'lucide-react';
function DollarSignIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<line x1="12" x2="12" y1="2" y2="22" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
);
}
interface EducationViewFiltersProps {
search: string;
onSearchChange: (value: string) => void;
filterLevel: string;
onFilterLevelChange: (value: string) => void;
filterPrice: string;
onFilterPriceChange: (value: string) => void;
}
export function EducationViewFilters({
search,
onSearchChange,
filterLevel,
onFilterLevelChange,
filterPrice,
onFilterPriceChange,
}: EducationViewFiltersProps) {
return (
<div className="flex flex-col md:flex-row gap-4 items-center bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50">
<div className="w-full md:w-96">
<SearchInput
placeholder="Search for courses, skills, or teachers..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<div className="flex items-center gap-2 bg-kodo-void rounded-lg p-1 border border-kodo-steel">
<Filter className="w-4 h-4 text-kodo-content-dim ml-2" />
<select
className="bg-transparent text-sm text-kodo-text-main focus:outline-none p-1 cursor-pointer"
value={filterLevel}
onChange={(e) => onFilterLevelChange(e.target.value)}
>
<option value="All">All Levels</option>
<option value="Beginner">Beginner</option>
<option value="Intermediate">Intermediate</option>
<option value="Advanced">Advanced</option>
</select>
</div>
<div className="flex items-center gap-2 bg-kodo-void rounded-lg p-1 border border-kodo-steel">
<DollarSignIcon className="w-4 h-4 text-kodo-content-dim ml-2" />
<select
className="bg-transparent text-sm text-kodo-text-main focus:outline-none p-1 cursor-pointer"
value={filterPrice}
onChange={(e) => onFilterPriceChange(e.target.value)}
>
<option value="All">All Prices</option>
<option value="Free">Free</option>
<option value="Paid">Paid</option>
</select>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,29 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { GraduationCap } from 'lucide-react';
interface EducationViewHeaderProps {
onMyCoursesClick?: () => void;
}
export function EducationViewHeader({ onMyCoursesClick }: EducationViewHeaderProps) {
return (
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
<div>
<h2 className="text-2xl font-display font-bold text-white mb-2">
ACADEMY
</h2>
<p className="text-kodo-content-dim font-mono text-sm">
Level up your skills. Earn certificates.
</p>
</div>
<Button
variant="glass"
icon={<GraduationCap className="w-4 h-4" />}
onClick={onMyCoursesClick}
>
MY LEARNING
</Button>
</div>
);
}

View file

@ -0,0 +1,30 @@
import React from 'react';
import { Skeleton } from '@/components/ui/skeleton';
export function EducationViewSkeleton() {
return (
<div className="space-y-8 animate-fadeIn pb-20 min-h-layout-page">
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
<div>
<Skeleton className="h-8 w-32 mb-2" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-11 w-40 rounded-lg" />
</div>
<div className="flex flex-col md:flex-row gap-4 items-center bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50">
<Skeleton className="h-10 w-full md:w-96 rounded-lg" />
<div className="flex gap-2 w-full md:w-auto">
<Skeleton className="h-9 w-28 rounded-lg" />
<Skeleton className="h-9 w-24 rounded-lg" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="aspect-video w-full rounded-xl" />
))}
</div>
</div>
);
}

View file

@ -0,0 +1,4 @@
export { EducationView } from './EducationView';
export { EducationViewSkeleton } from './EducationViewSkeleton';
export { useEducationView } from './useEducationView';
export type { EducationViewProps } from './types';

View file

@ -0,0 +1,8 @@
import type { Course } from '@/types';
export interface EducationViewProps {
onCourseClick?: (course: Course) => void;
onMyCoursesClick?: () => void;
/** When provided, used as initial data and catalog fetch is skipped (for stories: Empty). */
initialCourses?: Course[] | null;
}

View file

@ -0,0 +1,54 @@
import { useState, useEffect } from 'react';
import { educationService } from '@/services/educationService';
import { logger } from '@/utils/logger';
import type { Course } from '@/types';
export function useEducationView(initialCourses?: Course[] | null) {
const [search, setSearch] = useState('');
const [filterLevel, setFilterLevel] = useState<string>('All');
const [filterPrice, setFilterPrice] = useState<string>('All');
const [courses, setCourses] = useState<Course[]>(initialCourses ?? []);
const [loading, setLoading] = useState(initialCourses === undefined);
useEffect(() => {
if (initialCourses !== undefined) return;
const loadCourses = async () => {
setLoading(true);
try {
const data = await educationService.getCatalog();
setCourses(data);
} catch (e) {
logger.error('Failed to load courses', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
};
loadCourses();
}, [initialCourses]);
const filtered = courses.filter((c) => {
const matchSearch =
c.title.toLowerCase().includes(search.toLowerCase()) ||
(c.tags ?? []).some((t) => t.toLowerCase().includes(search.toLowerCase()));
const matchLevel = filterLevel === 'All' || c.level === filterLevel;
const matchPrice =
filterPrice === 'All' ||
(filterPrice === 'Free' ? c.price === 0 : (c.price ?? 0) > 0);
return matchSearch && matchLevel && matchPrice;
});
return {
search,
setSearch,
filterLevel,
setFilterLevel,
filterPrice,
setFilterPrice,
courses,
loading,
filtered,
};
}