refactor(web): split EducationView into education-view module
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c6c254bb05
commit
6833df9dc5
10 changed files with 299 additions and 171 deletions
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
4
apps/web/src/components/views/education-view/index.ts
Normal file
4
apps/web/src/components/views/education-view/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { EducationView } from './EducationView';
|
||||
export { EducationViewSkeleton } from './EducationViewSkeleton';
|
||||
export { useEducationView } from './useEducationView';
|
||||
export type { EducationViewProps } from './types';
|
||||
8
apps/web/src/components/views/education-view/types.ts
Normal file
8
apps/web/src/components/views/education-view/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue