diff --git a/src/App.tsx b/src/App.tsx index dc687a6..9e461db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { BootSequence } from './components/BootSequence' import { ECGAnimation } from './components/ECGAnimation' import { FloatingNav } from './components/FloatingNav' import { Hero } from './components/Hero' +import { Skills } from './components/Skills' function App() { const [phase, setPhase] = useState('boot') @@ -24,12 +25,7 @@ function App() {
-
-

- Skills -

-

Skills section will be built in Task 7

-
+

diff --git a/src/components/Skills.tsx b/src/components/Skills.tsx new file mode 100644 index 0000000..1e69f23 --- /dev/null +++ b/src/components/Skills.tsx @@ -0,0 +1,195 @@ +import { useRef, useState, useEffect } from 'react' +import { motion } from 'framer-motion' +import type { Skill } from '../types' +import { calculateSkillOffset } from '../lib/utils' + +const GAUGE_RADIUS = 34 +const GAUGE_CIRCUMFERENCE = 2 * Math.PI * GAUGE_RADIUS + +interface SkillGaugeProps { + skill: Skill + delay: number + isVisible: boolean +} + +function SkillGauge({ skill, delay, isVisible }: SkillGaugeProps) { + const [animated, setAnimated] = useState(false) + const strokeColor = skill.color === 'coral' ? '#FF6B6B' : '#00897B' + const hoverBg = skill.color === 'coral' ? 'hover:bg-coral-light' : 'hover:bg-teal-light' + + const targetOffset = calculateSkillOffset(skill.level, GAUGE_RADIUS) + + useEffect(() => { + if (isVisible && !animated) { + const timer = setTimeout(() => setAnimated(true), delay) + return () => clearTimeout(timer) + } + }, [isVisible, animated, delay]) + + return ( + + + + + + {skill.level}% + + + + {skill.name} + + + {skill.category} + + + ) +} + +interface SkillCategoryProps { + label: string + skills: Skill[] + isVisible: boolean + baseDelay: number +} + +function SkillCategory({ label, skills, isVisible, baseDelay }: SkillCategoryProps) { + return ( +
+

+ {label} +

+
+ {skills.map((skill, index) => ( + + ))} +
+
+ ) +} + +const skillsData: Skill[] = [ + { name: 'Python', level: 90, category: 'Technical', color: 'teal' }, + { name: 'SQL', level: 88, category: 'Technical', color: 'teal' }, + { name: 'Power BI', level: 92, category: 'Technical', color: 'teal' }, + { name: 'JS / TS', level: 70, category: 'Technical', color: 'teal' }, + { name: 'Data Analysis', level: 95, category: 'Technical', color: 'teal' }, + { name: 'Dashboard Dev', level: 88, category: 'Technical', color: 'teal' }, + { name: 'Algorithm Design', level: 82, category: 'Technical', color: 'teal' }, + { name: 'Data Pipelines', level: 80, category: 'Technical', color: 'teal' }, + + { name: 'Medicines Optimisation', level: 95, category: 'Clinical', color: 'coral' }, + { name: 'Pop. Health Analytics', level: 90, category: 'Clinical', color: 'coral' }, + { name: 'NICE TA', level: 85, category: 'Clinical', color: 'coral' }, + { name: 'Health Economics', level: 80, category: 'Clinical', color: 'coral' }, + { name: 'Clinical Pathways', level: 82, category: 'Clinical', color: 'coral' }, + { name: 'CD Assurance', level: 88, category: 'Clinical', color: 'coral' }, + + { name: 'Budget Mgmt', level: 90, category: 'Strategic', color: 'teal' }, + { name: 'Stakeholder Engagement', level: 88, category: 'Strategic', color: 'teal' }, + { name: 'Pharma Negotiation', level: 85, category: 'Strategic', color: 'teal' }, + { name: 'Team Development', level: 82, category: 'Strategic', color: 'teal' }, +] + +export function Skills() { + const sectionRef = useRef(null) + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + const element = sectionRef.current + if (!element) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true) + observer.unobserve(element) + } + }, + { threshold: 0.15, rootMargin: '0px' } + ) + + observer.observe(element) + return () => observer.disconnect() + }, []) + + const technicalSkills = skillsData.filter(s => s.category === 'Technical') + const clinicalSkills = skillsData.filter(s => s.category === 'Clinical') + const strategicSkills = skillsData.filter(s => s.category === 'Strategic') + + return ( +
+ + Skills & Expertise + + + + + +
+ ) +} diff --git a/src/hooks/useScrollReveal.ts b/src/hooks/useScrollReveal.ts new file mode 100644 index 0000000..2b0d226 --- /dev/null +++ b/src/hooks/useScrollReveal.ts @@ -0,0 +1,40 @@ +import { useEffect, useRef, useState, type RefObject } from 'react' + +interface UseScrollRevealOptions { + threshold?: number + rootMargin?: string + triggerOnce?: boolean +} + +export function useScrollReveal( + options: UseScrollRevealOptions = {} +): [RefObject, boolean] { + const { threshold = 0.15, rootMargin = '0px', triggerOnce = true } = options + const ref = useRef(null) + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + const element = ref.current + if (!element) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true) + if (triggerOnce) { + observer.unobserve(element) + } + } else if (!triggerOnce) { + setIsVisible(false) + } + }, + { threshold, rootMargin } + ) + + observer.observe(element) + + return () => observer.disconnect() + }, [threshold, rootMargin, triggerOnce]) + + return [ref, isVisible] +}