diff --git a/web-app/src/components/SkillCard.tsx b/web-app/src/components/SkillCard.tsx new file mode 100644 index 00000000..fe7ea3cd --- /dev/null +++ b/web-app/src/components/SkillCard.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Book, ArrowRight } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { SkillStarButton } from './SkillStarButton'; +import type { Skill } from '../types'; + +interface SkillCardProps { + skill: Skill; + starCount: number; +} + +export const SkillCard = React.memo(({ skill, starCount }: SkillCardProps) => { + return ( + + +
+
+
+ +
+ + {skill.category || 'Uncategorized'} + +
+ +
+ +

+ @{skill.name} +

+ +

+ {skill.description} +

+ +
+ Risk: {skill.risk || 'unknown'} + {skill.date_added && ( + 📅 {skill.date_added} + )} +
+ +
+ Read Skill +
+ +
+ ); +}); + +SkillCard.displayName = 'SkillCard'; diff --git a/web-app/src/pages/Home.tsx b/web-app/src/pages/Home.tsx index b5263a50..543846c7 100644 --- a/web-app/src/pages/Home.tsx +++ b/web-app/src/pages/Home.tsx @@ -1,61 +1,38 @@ -import { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import { Search, Filter, Book, AlertCircle, ArrowRight, RefreshCw, ArrowUpDown } from 'lucide-react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Search, Filter, AlertCircle, RefreshCw, ArrowUpDown } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; -import { supabase } from '../lib/supabase'; -import { SkillStarButton } from '../components/SkillStarButton'; -import type { Skill, StarMap, SyncMessage, CategoryStats } from '../types'; +import { VirtuosoGrid } from 'react-virtuoso'; +import debounce from 'lodash.debounce'; +import { useSkills } from '../context/SkillContext'; +import { SkillCard } from '../components/SkillCard'; +import type { Skill, SyncMessage, CategoryStats } from '../types'; export function Home(): React.ReactElement { - const [skills, setSkills] = useState([]); - const [filteredSkills, setFilteredSkills] = useState([]); + const { skills, stars, loading, refreshSkills } = useSkills(); const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); - const [loading, setLoading] = useState(true); - const [stars, setStars] = useState({}); const [sortBy, setSortBy] = useState('default'); const [syncing, setSyncing] = useState(false); const [syncMsg, setSyncMsg] = useState(null); - useEffect(() => { - const fetchSkillsAndStars = async () => { - try { - // Fetch basic skill data - const res = await fetch('/skills.json'); - const data = await res.json(); - - setSkills(data); - setFilteredSkills(data); - - // Fetch star counts if supabase is configured - if (supabase) { - const { data: starData, error } = await supabase - .from('skill_stars') - .select('skill_id, star_count'); - - if (!error && starData) { - const starMap: StarMap = {}; - starData.forEach((item: { skill_id: string; star_count: number }) => { - starMap[item.skill_id] = item.star_count; - }); - setStars(starMap); - } - } - } catch (err) { - console.error('Failed to load skills', err); - } finally { - setLoading(false); - } - }; - - fetchSkillsAndStars(); - }, []); + // Debounce search input to avoid excessive filtering on every keystroke + const debouncedSetSearch = useCallback( + debounce((value: string) => { + setDebouncedSearch(value); + }, 300), + [] + ); useEffect(() => { + debouncedSetSearch(search); + }, [search, debouncedSetSearch]); + + const filteredSkills = useMemo(() => { let result = [...skills]; - if (search) { - const lowerSearch = search.toLowerCase(); + if (debouncedSearch) { + const lowerSearch = debouncedSearch.toLowerCase(); result = result.filter(skill => skill.name.toLowerCase().includes(lowerSearch) || skill.description.toLowerCase().includes(lowerSearch) @@ -75,20 +52,24 @@ export function Home(): React.ReactElement { result = [...result].sort((a, b) => a.name.localeCompare(b.name)); } - setFilteredSkills(result); - }, [search, categoryFilter, sortBy, skills, stars]); + return result; + }, [debouncedSearch, categoryFilter, sortBy, skills, stars]); // Sort categories by count (most skills first), with 'uncategorized' at the end - const categoryStats: CategoryStats = {}; - skills.forEach(skill => { - categoryStats[skill.category] = (categoryStats[skill.category] || 0) + 1; - }); + const { categories, categoryStats } = useMemo(() => { + const stats: CategoryStats = {}; + skills.forEach(skill => { + stats[skill.category] = (stats[skill.category] || 0) + 1; + }); - const categories = ['all', ...Object.keys(categoryStats) - .filter(cat => cat !== 'uncategorized') - .sort((a, b) => categoryStats[b] - categoryStats[a]), - ...(categoryStats['uncategorized'] ? ['uncategorized'] : []) - ]; + const cats = ['all', ...Object.keys(stats) + .filter(cat => cat !== 'uncategorized') + .sort((a, b) => stats[b] - stats[a]), + ...(stats['uncategorized'] ? ['uncategorized'] : []) + ]; + + return { categories: cats, categoryStats: stats }; + }, [skills]); const handleSync = async () => { setSyncing(true); @@ -101,11 +82,7 @@ export function Home(): React.ReactElement { setSyncMsg({ type: 'info', text: 'â„šī¸ Skills are already up to date!' }); } else { setSyncMsg({ type: 'success', text: `✅ Synced ${data.count} skills!` }); - // Reload skills data only when there are actual updates - const freshRes = await fetch('/skills.json'); - const freshData = await freshRes.json(); - setSkills(freshData); - setFilteredSkills(freshData); + await refreshSkills(); } } else { setSyncMsg({ type: 'error', text: `❌ ${data.error}` }); @@ -119,7 +96,7 @@ export function Home(): React.ReactElement { }; return ( -
+

Explore Skills

@@ -127,13 +104,12 @@ export function Home(): React.ReactElement {
{syncMsg && ( - + {syncMsg.text} )} @@ -189,72 +165,31 @@ export function Home(): React.ReactElement {
-
- - {loading ? ( - [...Array(8)].map((_, i) => ( +
+ {loading ? ( +
+ {[...Array(8)].map((_, i) => (
- )) - ) : filteredSkills.length === 0 ? ( -
- -

No skills found

-

Try adjusting your search or filter.

-
- ) : ( - filteredSkills.map((skill) => ( - - -
-
-
- -
- - {skill.category || 'Uncategorized'} - -
- -
- -

- @{skill.name} -

- -

- {skill.description} -

- -
- Risk: {skill.risk || 'unknown'} - {skill.date_added && ( - 📅 {skill.date_added} - )} -
- -
- Read Skill -
- -
- )) - )} - + ))} +
+ ) : filteredSkills.length === 0 ? ( +
+ +

No skills found

+

Try adjusting your search or filter.

+
+ ) : ( + { + const skill = filteredSkills[index]; + return ; + }} + /> + )}
);