diff --git a/eslint.config.js b/eslint.config.js index 62a6f0a..a927e8f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,48 +1,46 @@ -import js from '@eslint/js' -import globals from 'globals' -import react from 'eslint-plugin-react' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import typescript from '@typescript-eslint/eslint-plugin' -import parser from '@typescript-eslint/parser' -import next from 'eslint-plugin-next' +import js from "@eslint/js"; +import globals from "globals"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import typescript from "@typescript-eslint/eslint-plugin"; +import parser from "@typescript-eslint/parser"; export default [ - { ignores: ['dist', '.next'] }, + { ignores: ["dist", ".next", "out", "node_modules"] }, { - files: ['**/*.{js,jsx,ts,tsx}'], + files: ["**/*.{js,jsx,ts,tsx}"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, parser: parser, parserOptions: { - ecmaVersion: 'latest', + ecmaVersion: "latest", ecmaFeatures: { jsx: true }, - sourceType: 'module', + sourceType: "module", }, }, - settings: { react: { version: '18.3' } }, + settings: { react: { version: "18.3" } }, plugins: { react, - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - '@typescript-eslint': typescript, - next, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + "@typescript-eslint": typescript, }, rules: { ...js.configs.recommended.rules, ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, + ...react.configs["jsx-runtime"].rules, ...reactHooks.configs.recommended.rules, ...typescript.configs.recommended.rules, - ...next.configs.recommended.rules, - 'react/jsx-no-target-blank': 'off', - 'react-refresh/only-export-components': [ - 'warn', + "react/jsx-no-target-blank": "off", + "react/prop-types": "off", // Using TypeScript for prop validation + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], - '@typescript-eslint/no-unused-vars': 'off', - 'react/no-unknown-property': 'off', + "@typescript-eslint/no-unused-vars": "off", + "react/no-unknown-property": "off", }, }, -] \ No newline at end of file +]; diff --git a/src/components/discovery/DiscoveryCard.tsx b/src/components/discovery/DiscoveryCard.tsx index 2685c2d..ac6b644 100644 --- a/src/components/discovery/DiscoveryCard.tsx +++ b/src/components/discovery/DiscoveryCard.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import React from "react"; +import React, { memo } from "react"; import { PostMeta } from "../../utils/markdown"; import SafeImage from "../ui/SafeImage"; @@ -7,16 +7,25 @@ type Props = { post: PostMeta; }; -export default function DiscoveryCard({ post }: Props) { +const DiscoveryCard = memo(({ post }: Props) => { return (
- + {/* Fixed-height image area so cards align evenly */}
{post.image ? ( - + ) : ( -
No Image
+
+ No Image +
)}
@@ -27,7 +36,10 @@ export default function DiscoveryCard({ post }: Props) {

{post.description}

{post.tags?.map((t) => ( - + {t} ))} @@ -36,4 +48,8 @@ export default function DiscoveryCard({ post }: Props) {
); -} +}); + +DiscoveryCard.displayName = "DiscoveryCard"; + +export default DiscoveryCard; diff --git a/src/components/discovery/DiscoveryList.tsx b/src/components/discovery/DiscoveryList.tsx index ae02012..11dddc2 100644 --- a/src/components/discovery/DiscoveryList.tsx +++ b/src/components/discovery/DiscoveryList.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { PostMeta } from "../../utils/markdown"; import DiscoveryCard from "./DiscoveryCard"; @@ -8,13 +8,20 @@ type Props = { export default function DiscoveryList({ posts }: Props) { if (!posts.length) - return

No posts yet, come back later?

; - // Ensure posts are sorted by date (newest first). Some posts may omit dates. - const sorted = [...posts].sort((a, b) => { - const da = a.date ? new Date(a.date).getTime() : 0; - const db = b.date ? new Date(b.date).getTime() : 0; - return db - da; //db - da for descending order (newest first) - }); + return ( +

+ No posts yet, come back later? +

+ ); + + // Ensure posts are sorted by date (newest first). Memoize to avoid re-sorting on every render. + const sorted = useMemo(() => { + return [...posts].sort((a, b) => { + const da = a.date ? new Date(a.date).getTime() : 0; + const db = b.date ? new Date(b.date).getTime() : 0; + return db - da; //db - da for descending order (newest first) + }); + }, [posts]); return (
diff --git a/src/components/layout/Background.tsx b/src/components/layout/Background.tsx index 1835931..fba9d4e 100644 --- a/src/components/layout/Background.tsx +++ b/src/components/layout/Background.tsx @@ -1,8 +1,9 @@ -const Bg = () => { - return ( -
-
- ); -}; +import { memo } from "react"; + +const Bg = memo(() => { + return
; +}); + +Bg.displayName = "Background"; export default Bg; diff --git a/src/components/layout/MyHead.tsx b/src/components/layout/MyHead.tsx index c34e20c..ca2c402 100644 --- a/src/components/layout/MyHead.tsx +++ b/src/components/layout/MyHead.tsx @@ -1,7 +1,7 @@ import { useGLTF } from "@react-three/drei"; import { Canvas, useFrame } from "@react-three/fiber"; import { easing } from "maath"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, memo } from "react"; import { Object3D, LoopOnce, AnimationMixer } from "three"; function Model({ onLoaded }: { onLoaded: () => void }) { const gltf = useGLTF("/assets/myHead.gltf"); @@ -38,16 +38,6 @@ function Model({ onLoaded }: { onLoaded: () => void }) { }; }, [scene, animations]); - useEffect(() => { - const animate = () => { - requestAnimationFrame(animate); - if (mixer.current) { - mixer.current.update(0.01); - } - }; - animate(); - }, []); - useEffect(() => { if (head.current) { head.current.rotation.set(0, Math.PI, 0); @@ -55,16 +45,24 @@ function Model({ onLoaded }: { onLoaded: () => void }) { } }, []); - useFrame((state, dt) => { - dummy.lookAt(cursor.x, cursor.y, 1); - dummy.rotation.y += Math.PI; - easing.dampQ(head.current.quaternion, dummy.quaternion, 0.15, dt); + useFrame((state, delta) => { + // Update animation mixer if active + if (mixer.current) { + mixer.current.update(delta); + } + + // Update head rotation to follow cursor + if (head.current) { + dummy.lookAt(cursor.x, cursor.y, 1); + dummy.rotation.y += Math.PI; + easing.dampQ(head.current.quaternion, dummy.quaternion, 0.15, delta); + } }); return ; } -const HeadRender = () => { +const HeadRender = memo(() => { const [loading, setLoading] = useState(true); return (
@@ -73,12 +71,18 @@ const HeadRender = () => {
)} - + setLoading(false)} />
); -}; +}); + +HeadRender.displayName = "HeadRender"; export default HeadRender; diff --git a/src/components/layout/Nav.tsx b/src/components/layout/Nav.tsx index bb0cda4..ad9cfcd 100644 --- a/src/components/layout/Nav.tsx +++ b/src/components/layout/Nav.tsx @@ -27,7 +27,9 @@ const Nav = () => { (entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; - setActiveSection(entry.target.id); + // Only update if the active section has actually changed + const newSection = entry.target.id; + setActiveSection((prev) => (prev !== newSection ? newSection : prev)); }); }, { threshold: 0.01 }, diff --git a/src/components/sections/Contact.tsx b/src/components/sections/Contact.tsx index a714d1d..333d793 100644 --- a/src/components/sections/Contact.tsx +++ b/src/components/sections/Contact.tsx @@ -1,182 +1,197 @@ -import React from "react"; +import React, { useCallback, useMemo, memo } from "react"; import toast, { Toaster } from "react-hot-toast"; -const Contact = () => { - const copyToClipboard = (text: string, platform: string) => { - navigator.clipboard.writeText(text).then(() => { - toast.success(`${platform} copied to clipboard!`, { - duration: 2000, - position: 'top-left', - style: { - background: 'var(--color-surface)', - color: 'var(--color-text-primary)', - border: '1px solid var(--color-secondary)', - }, - }); - }).catch(() => { - toast.error('Failed to copy to clipboard', { - duration: 2000, - position: 'top-left', +interface ContactLink { + icon: JSX.Element; + platform: string; + handle: string; + copyText?: string; + url?: string; + action: "copy" | "link"; +} + +const Contact = memo(() => { + const copyToClipboard = useCallback((text: string, platform: string) => { + navigator.clipboard + .writeText(text) + .then(() => { + toast.success(`${platform} copied to clipboard!`, { + duration: 2000, + position: "top-left", + style: { + background: "var(--color-surface)", + color: "var(--color-text-primary)", + border: "1px solid var(--color-secondary)", + }, + }); + }) + .catch(() => { + toast.error("Failed to copy to clipboard", { + duration: 2000, + position: "top-left", + }); }); - }); - }; + }, []); - const contactLinks = [ - - { - icon: ( - - - - - ), - platform: "Work Email", - handle: "contact@keyyard.xyz", - copyText: "contact@keyyard.xyz", - action: "copy" - }, - { - icon: ( - - - - - ), - platform: "Personal", - handle: "keyyard8888@gmail.com", - copyText: "keyyard8888@gmail.com", - action: "copy" - }, - - { - icon: ( - - ), - platform: "Discord", - handle: "keyyard", - copyText: "keyyard", - action: "copy" - }, - { - icon: ( - - ), - platform: "GitHub", - handle: "github.com/keyyard", - url: "https://github.com/keyyard", - action: "link" - }, - { - icon: ( - - ), - platform: "X (Twitter)", - handle: "@keyyard", - url: "https://twitter.com/keyyard", - action: "link" - }, - { - icon: ( - - ), - platform: "YouTube", - handle: "@Keyyard", - url: "https://youtube.com/c/keyyard", - action: "link" - }, - ]; + const contactLinks = useMemo( + () => [ + { + icon: ( + + + + + ), + platform: "Work Email", + handle: "contact@keyyard.xyz", + copyText: "contact@keyyard.xyz", + action: "copy", + }, + { + icon: ( + + + + + ), + platform: "Personal", + handle: "keyyard8888@gmail.com", + copyText: "keyyard8888@gmail.com", + action: "copy", + }, - const handleContactClick = (link: any) => { - if (link.action === "copy") { - copyToClipboard(link.copyText, link.platform); - } else if (link.action === "link") { - window.open(link.url, "_blank", "noopener,noreferrer"); - } - }; + { + icon: ( + + ), + platform: "Discord", + handle: "keyyard", + copyText: "keyyard", + action: "copy", + }, + { + icon: ( + + ), + platform: "GitHub", + handle: "github.com/keyyard", + url: "https://github.com/keyyard", + action: "link", + }, + { + icon: ( + + ), + platform: "X (Twitter)", + handle: "@keyyard", + url: "https://twitter.com/keyyard", + action: "link", + }, + { + icon: ( + + ), + platform: "YouTube", + handle: "@Keyyard", + url: "https://youtube.com/c/keyyard", + action: "link", + }, + ], + [], + ); + + const handleContactClick = useCallback( + (link: ContactLink) => { + if (link.action === "copy" && link.copyText) { + copyToClipboard(link.copyText, link.platform); + } else if (link.action === "link" && link.url) { + window.open(link.url, "_blank", "noopener,noreferrer"); + } + }, + [copyToClipboard], + ); return (
-

- Let's Connect -

+

Let's Connect

I'm open to collabs, freelance, or just chatting.

@@ -185,27 +200,27 @@ const Contact = () => {

{contactLinks.map((link, index) => ( -
handleContactClick(link)} className="contact-link" - style={{ cursor: 'pointer' }} + style={{ cursor: "pointer" }} > {link.icon} {link.platform} - - {link.handle} - + {link.handle}
))}
- +
Copyright © 2025 Keyyard
); -}; +}); + +Contact.displayName = "Contact"; export default Contact; diff --git a/src/components/sections/Experiences.tsx b/src/components/sections/Experiences.tsx index 82311fc..233eb59 100644 --- a/src/components/sections/Experiences.tsx +++ b/src/components/sections/Experiences.tsx @@ -1,25 +1,44 @@ import { motion } from "framer-motion"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback, useRef, memo } from "react"; import { useInView } from "react-intersection-observer"; import { experiences } from "../../data"; -const Experiences = () => { +const Experiences = memo(() => { const [selectedExperience, setSelectedExperience] = useState(experiences[0]); const [resolution, setResolution] = useState(0); + const timeoutIdRef = useRef(); useEffect(() => { if (typeof window !== "undefined") { setResolution(window.innerWidth); + const handleResize = () => { - setResolution(window.innerWidth); + // Debounce resize event to prevent excessive re-renders + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } + timeoutIdRef.current = setTimeout(() => { + setResolution(window.innerWidth); + }, 150); }; + window.addEventListener("resize", handleResize); return () => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } window.removeEventListener("resize", handleResize); }; } }, []); + const handleExperienceClick = useCallback( + (exp: (typeof experiences)[number]) => { + setSelectedExperience(exp); + }, + [], + ); + const { ref: sectionRef, inView: sectionInView } = useInView({ triggerOnce: true, threshold: 0.01, @@ -36,7 +55,7 @@ const Experiences = () => { className={`experience-card ${ selectedExperience === exp ? "experience-card--selected" : "" }`} - onClick={() => setSelectedExperience(exp)} + onClick={() => handleExperienceClick(exp)} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} ref={sectionRef} @@ -65,7 +84,7 @@ const Experiences = () => { ))} - {resolution <= 1024 && selectedExperience && ( + {resolution <= 1024 && selectedExperience && ( { + rel="noopener noreferrer" + > {selectedExperience.company_name} @@ -130,6 +150,8 @@ const Experiences = () => {
); -}; +}); + +Experiences.displayName = "Experiences"; -export default Experiences; \ No newline at end of file +export default Experiences; diff --git a/src/components/sections/Hero.tsx b/src/components/sections/Hero.tsx index 878d872..c8d5163 100644 --- a/src/components/sections/Hero.tsx +++ b/src/components/sections/Hero.tsx @@ -1,5 +1,6 @@ import { motion } from "framer-motion"; import dynamic from "next/dynamic"; +import { useCallback, memo } from "react"; import { introductionText } from "../../data"; import Bg from "../layout/Background"; @@ -8,7 +9,17 @@ const HeadRender = dynamic(() => import("../layout/MyHead"), { loading: () =>
, }); -export function Hero() { +const Hero = memo(() => { + const scrollToProjects = useCallback(() => { + const projectsSection = document.getElementById("projects"); + projectsSection?.scrollIntoView({ behavior: "smooth" }); + }, []); + + const scrollToContact = useCallback(() => { + const contactSection = document.getElementById("contact"); + contactSection?.scrollIntoView({ behavior: "smooth" }); + }, []); + return (
@@ -57,25 +68,20 @@ export function Hero() {
-
); -} +}); + +Hero.displayName = "Hero"; + +export { Hero }; diff --git a/src/components/sections/Projects.tsx b/src/components/sections/Projects.tsx index 77f8fd8..ada67cb 100644 --- a/src/components/sections/Projects.tsx +++ b/src/components/sections/Projects.tsx @@ -1,16 +1,17 @@ - -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, memo } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Projects } from "../../data"; import { Suspense, lazy } from "react"; const SafeImage = lazy(() => import("../ui/SafeImage")); -type ProjectType = typeof Projects[number]; +type ProjectType = (typeof Projects)[number]; -const ProjectsSection = () => { +const ProjectsSection = memo(() => { const [loading, setLoading] = useState(true); - const [selectedProject, setSelectedProject] = useState(null); + const [selectedProject, setSelectedProject] = useState( + null, + ); useEffect(() => { const timer = setTimeout(() => setLoading(false), 600); @@ -18,8 +19,11 @@ const ProjectsSection = () => { }, []); const skeletons = Array.from({ length: 3 }); - const openModal = (proj: ProjectType) => setSelectedProject(proj); - const closeModal = () => setSelectedProject(null); + const openModal = useCallback( + (proj: ProjectType) => setSelectedProject(proj), + [], + ); + const closeModal = useCallback(() => setSelectedProject(null), []); return (
@@ -30,7 +34,10 @@ const ProjectsSection = () => {
{loading ? skeletons.map((_, i) => ( -
+
@@ -66,7 +73,11 @@ const ProjectsSection = () => { {/* Project Icon */}
{proj.icon && ( - }> + + } + > { proj.status === "Live" ? "bg-green-500 bg-opacity-20 text-green-400 border border-green-500 border-opacity-30" : proj.status === "In Development" - ? "bg-yellow-500 bg-opacity-20 text-yellow-400 border border-yellow-500 border-opacity-30" - : "bg-blue-500 bg-opacity-20 text-blue-400 border border-blue-500 border-opacity-30" + ? "bg-yellow-500 bg-opacity-20 text-yellow-400 border border-yellow-500 border-opacity-30" + : "bg-blue-500 bg-opacity-20 text-blue-400 border border-blue-500 border-opacity-30" }`} > {proj.status} @@ -142,7 +153,7 @@ const ProjectsSection = () => { initial={{ scale: 0.95 }} animate={{ scale: 1 }} exit={{ scale: 0.95 }} - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} >
); -}; +}); + +ProjectsSection.displayName = "ProjectsSection"; -export default ProjectsSection; \ No newline at end of file +export default ProjectsSection; diff --git a/src/components/ui/SafeCarousel.tsx b/src/components/ui/SafeCarousel.tsx index e959235..5199368 100644 --- a/src/components/ui/SafeCarousel.tsx +++ b/src/components/ui/SafeCarousel.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, memo } from "react"; import { Carousel } from "react-responsive-carousel"; -import SafeImage from './SafeImage'; -import { filterWorkingImages } from '../../utils/imageUtils'; +import SafeImage from "./SafeImage"; +import { filterWorkingImages } from "../../utils/imageUtils"; interface SafeCarouselProps { images: string[]; @@ -9,58 +9,66 @@ interface SafeCarouselProps { className?: string; } -const SafeCarousel: React.FC = ({ images, alt, className = "" }) => { - const [workingImages, setWorkingImages] = useState(images); - const [isLoading, setIsLoading] = useState(true); +const SafeCarousel: React.FC = memo( + ({ images, alt, className = "" }) => { + const [workingImages, setWorkingImages] = useState(images); + const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - const checkImages = async () => { - setIsLoading(true); - // For now, just use all images and let SafeImage handle errors - // In production, you might want to preload and filter - setWorkingImages(images); - setIsLoading(false); - }; + useEffect(() => { + const checkImages = async () => { + setIsLoading(true); + // For now, just use all images and let SafeImage handle errors + // In production, you might want to preload and filter + setWorkingImages(images); + setIsLoading(false); + }; - checkImages(); - }, [images]); + checkImages(); + }, [images]); - if (isLoading) { - return ( -
-
Loading images...
-
- ); - } + if (isLoading) { + return ( +
+
Loading images...
+
+ ); + } + + if (workingImages.length === 0) { + return ( +
+
No images available
+
+ ); + } - if (workingImages.length === 0) { return ( -
-
No images available
-
+ 1} + > + {workingImages.map((img, imgIndex) => ( +
+ +
+ ))} +
); - } + }, +); - return ( - 1} - > - {workingImages.map((img, imgIndex) => ( -
- -
- ))} -
- ); -}; +SafeCarousel.displayName = "SafeCarousel"; export default SafeCarousel; diff --git a/src/components/ui/SafeImage.tsx b/src/components/ui/SafeImage.tsx index f29fe6a..78dbefd 100644 --- a/src/components/ui/SafeImage.tsx +++ b/src/components/ui/SafeImage.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, memo } from "react"; interface SafeImageProps { src: string; @@ -9,44 +9,43 @@ interface SafeImageProps { showPlaceholderOnError?: boolean; } -const SafeImage: React.FC = ({ - src, - alt, - className = "", - fallbackSrc = "/image-placeholder.svg", - onError, - showPlaceholderOnError = true -}) => { - const [imgSrc, setImgSrc] = useState(src); - const [hasError, setHasError] = useState(false); +const SafeImage: React.FC = memo( + ({ + src, + alt, + className = "", + fallbackSrc = "/image-placeholder.svg", + onError, + showPlaceholderOnError = true, + }) => { + const [imgSrc, setImgSrc] = useState(src); + const [hasError, setHasError] = useState(false); - const handleError = () => { - if (!hasError) { - setHasError(true); - if (showPlaceholderOnError && fallbackSrc) { - setImgSrc(fallbackSrc); - } - // Only log warning, don't spam console - console.warn(`Failed to load image: ${src}`); - if (onError) { - onError(); + const handleError = () => { + if (!hasError) { + setHasError(true); + if (showPlaceholderOnError && fallbackSrc) { + setImgSrc(fallbackSrc); + } + // Only log warning, don't spam console + console.warn(`Failed to load image: ${src}`); + if (onError) { + onError(); + } } + }; + + // Don't render anything if there's an error and no fallback should be shown + if (hasError && !showPlaceholderOnError) { + return null; } - }; - // Don't render anything if there's an error and no fallback should be shown - if (hasError && !showPlaceholderOnError) { - return null; - } + return ( + {alt} + ); + }, +); - return ( - {alt} - ); -}; +SafeImage.displayName = "SafeImage"; export default SafeImage; diff --git a/src/pages/discovery/index.tsx b/src/pages/discovery/index.tsx index 8f2a46a..7770b78 100644 --- a/src/pages/discovery/index.tsx +++ b/src/pages/discovery/index.tsx @@ -29,22 +29,36 @@ export default function DiscoveryIndex({ posts }: { posts: any[] }) { return Array.from(s).sort(); }, [posts]); - const filtered = posts.filter((p: any) => { - if (filter && !(p.tags || []).includes(filter)) return false; - if (q && !(p.title + p.description).toLowerCase().includes(q.toLowerCase())) return false; - return true; - }); + const filtered = useMemo(() => { + return posts.filter((p: any) => { + if (filter && !(p.tags || []).includes(filter)) return false; + if ( + q && + !(p.title + p.description).toLowerCase().includes(q.toLowerCase()) + ) + return false; + return true; + }); + }, [posts, filter, q]); return ( <> - Keyyard Discovery - + Keyyard Discovery + -
+
-