diff --git a/bun.lockb b/bun.lockb index 1346b9d..a0a7aa5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 33c12b6..439ba4d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@radix-ui/react-slot": "^1.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "embla-carousel-autoplay": "^8.1.5", + "embla-carousel-react": "^8.1.5", "front-matter": "^4.0.2", "lucide-react": "^0.390.0", "marked": "^12.0.2", diff --git a/src/app/_content/About.tsx b/src/app/_content/About.tsx index f8eb953..7e85f45 100644 --- a/src/app/_content/About.tsx +++ b/src/app/_content/About.tsx @@ -1,6 +1,6 @@ "use client" -import Icon from "@/components/global/Icon" +import ExternalLink from "@/components/global/ExternalLink" import { useApi } from "@/components/providers/DataProvider" import Image from "next/image" @@ -8,6 +8,17 @@ export default function Footer() { const { data } = useApi() const profile = data.profile.attributes const { width, height, src, alt } = profile.bg + + const links = [ + { + href: "https://github.com/josephdburdick/j0e", + children: "View Source Code", + }, + { + href: "https://pagespeed.web.dev/analysis/https-j0e-me/3wq088el4g?form_factor=mobile", + children: "PageSpeed Insights", + }, + ] return (
-
+
About this site

- Data composed in front-matter format, front-end statically - generated with Next, server hosted with Github actions. -

-

- - View Source Code - - - + Edited in{" "} + Neovim, + composed in{" "} + + Front Matter + + , statically generated with{" "} + Next.js, + server hosted with{" "} + + Github Actions + + .

+
+ {links.map((link, key) => ( + + {link.children} + + ))} +
diff --git a/src/app/_content/Intro.tsx b/src/app/_content/Intro.tsx index 9349aa2..e3ec94f 100644 --- a/src/app/_content/Intro.tsx +++ b/src/app/_content/Intro.tsx @@ -1,13 +1,12 @@ "use client" import DarkModeToggle from "@/components/global/DarkModeToggle" +import HeaderAd from "@/components/global/HeaderAd" import Icon from "@/components/global/Icon" import LogoMarquee from "@/components/global/LogoMarquee" import MainHeader from "@/components/global/MainHeader" import MainNav from "@/components/global/MainNav" -import Swap from "@/components/global/Swap" import WeatherComponent from "@/components/global/Weather" -import WorkAvailability from "@/components/global/WorkAvailability" import { useApi } from "@/components/providers/DataProvider" import { ContactLink } from "@/lib/types" import { cn } from "@/lib/utils" @@ -45,11 +44,7 @@ function Intro() { )} > - } - secondComponent={} - /> - {/* */} +
diff --git a/src/components/global/DarkModeToggle.tsx b/src/components/global/DarkModeToggle.tsx index 5fd1315..b91716f 100644 --- a/src/components/global/DarkModeToggle.tsx +++ b/src/components/global/DarkModeToggle.tsx @@ -1,6 +1,6 @@ "use client" -import { buttonVariants } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" import { cn } from "@/lib/utils" import { useEffect, useState } from "react" @@ -25,11 +25,17 @@ const DarkModeToggle = () => { }, [darkMode]) return ( - + ) } diff --git a/src/components/global/ExternalLink.tsx b/src/components/global/ExternalLink.tsx new file mode 100644 index 0000000..1c9ca08 --- /dev/null +++ b/src/components/global/ExternalLink.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/lib/utils" +import { PropsWithChildren } from "react" + +import Icon from "./Icon" + +type Props = PropsWithChildren & { + href: string; + className?: string; +}; + +export default function ExternalLink(props: Props) { + const { href, children, className = "" } = props + return ( + + {children} + + + + ) +} diff --git a/src/components/global/HeaderAd.tsx b/src/components/global/HeaderAd.tsx new file mode 100644 index 0000000..00d5e14 --- /dev/null +++ b/src/components/global/HeaderAd.tsx @@ -0,0 +1,103 @@ +"use client" + +import { useApi } from "@/components/providers/DataProvider" +import { + Carousel, + CarouselContent, + CarouselItem, +} from "@/components/ui/carousel" +import { type CarouselApi } from "@/components/ui/carousel" +import { ContactLink } from "@/lib/types" +import { useEffect, useRef, useState } from "react" + +import { Button } from "../ui/button" +import Icon from "./Icon" +import MainNav from "./MainNav" +import WorkAvailability from "./WorkAvailability" + +export default function HeaderAd() { + const [api, setApi] = useState(null) + const { data } = useApi() + const links: ContactLink[] = Object.values(data.profile.attributes.links) + + const intervalRef = useRef(null) + const [isPaused, setIsPaused] = useState(false) + + useEffect(() => { + const updateAutoplay = (currentSlide: number) => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current) + } + + const delay = currentSlide === 0 ? 6000 : 2000 + + intervalRef.current = window.setInterval(() => { + if (api && !isPaused) { + api.scrollNext() + } + }, delay) + } + if (!api) { + return + } + + const handleSelect = () => { + const currentSlide = api.selectedScrollSnap() + updateAutoplay(currentSlide) + } + + api.on("select", handleSelect) + updateAutoplay(api.selectedScrollSnap()) + + return () => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current) + } + api.off("select", handleSelect) + } + }, [api, isPaused]) + + const handleMouseEnter = () => { + if (api?.selectedScrollSnap() === 0) { + setIsPaused(true) + } + } + + const handleMouseLeave = () => { + if (api?.selectedScrollSnap() === 0) { + setIsPaused(false) + } + } + + return ( + + + + + + + + + + + + + ) +} diff --git a/src/components/global/MainHeader.tsx b/src/components/global/MainHeader.tsx index 1c4dc60..b01f536 100644 --- a/src/components/global/MainHeader.tsx +++ b/src/components/global/MainHeader.tsx @@ -14,6 +14,7 @@ export const MainHeader = forwardRef( (props: Props, ref: ForwardedRef): ReactNode => { const { className } = props const { data } = useApi() + const { name } = data.profile const { logo } = data.site.attributes return ( @@ -31,9 +32,9 @@ export const MainHeader = forwardRef( height={logo.height} alt={logo.alt} /> - Joe Burdick + {name} -
+
{props.children}
diff --git a/src/components/global/MainNav.tsx b/src/components/global/MainNav.tsx index c7411ae..7659fa7 100644 --- a/src/components/global/MainNav.tsx +++ b/src/components/global/MainNav.tsx @@ -13,19 +13,28 @@ import { } from "@/components/ui/drawer" import { ContactLink } from "@/lib/types" import { cn } from "@/lib/utils" +import { PropsWithChildren } from "react" import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover" import Icon from "./Icon" -type Props = { +type Props = PropsWithChildren & { links: ContactLink[]; + className?: string; title?: string; description?: string; iconOnly?: boolean; }; export default function MainNav(props: Props) { - const { iconOnly = false, links: linksProp = [], title, description } = props + const { + children = null, + iconOnly = false, + links: linksProp = [], + title, + description, + } = props + const hasText = title || description const renderTrigger = ( ) - const Nav = () => ( ) - return ( <>
- {renderTrigger} + {children || renderTrigger} -
-

{title}

-

{description}

-
+ {hasText && ( +
+

{title}

+

{description}

+
+ )}
- {renderTrigger} + {children || renderTrigger}
diff --git a/src/components/global/Swap.tsx b/src/components/global/Swap.tsx deleted file mode 100644 index feaa12d..0000000 --- a/src/components/global/Swap.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { cn } from "@/lib/utils" -import React, { useEffect, useRef, useState } from "react" - -interface SwapProps { - firstComponent: React.ReactNode; - secondComponent: React.ReactNode; - firstDuration?: number; // Duration to show the first component - secondDuration?: number; // Duration to show the second component - className?: string; -} - -const Swap: React.FC = ({ - firstComponent, - secondComponent, - firstDuration = 5000, - secondDuration = 1000, - className = "", -}) => { - const [showFirst, setShowFirst] = useState(true) - const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) - const firstRef = useRef(null) - const secondRef = useRef(null) - const containerRef = useRef(null) - - useEffect(() => { - let intervalId: NodeJS.Timeout - - const switchComponent = () => { - setShowFirst((prev) => !prev) - const nextDuration = showFirst ? secondDuration : firstDuration - intervalId = setTimeout(switchComponent, nextDuration) - } - - intervalId = setTimeout(switchComponent, firstDuration) // Start with the first component duration - - return () => clearTimeout(intervalId) // Cleanup timeout on component unmount - }, [firstDuration, secondDuration, showFirst]) - - useEffect(() => { - const updateDimensions = () => { - if (containerRef.current) { - const firstHeight = firstRef.current?.offsetHeight || 0 - const firstWidth = firstRef.current?.offsetWidth || 0 - const secondHeight = secondRef.current?.offsetHeight || 0 - const secondWidth = secondRef.current?.offsetWidth || 0 - setDimensions({ - width: Math.max(firstWidth, secondWidth), - height: Math.max(firstHeight, secondHeight), - }) - } - } - - updateDimensions() - window.addEventListener("resize", updateDimensions) - - return () => { - window.removeEventListener("resize", updateDimensions) - } - }, [showFirst]) - - return ( -
-
- {firstComponent} -
-
- {secondComponent} -
-
- ) -} - -export default Swap diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 0000000..f9b6840 --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,262 @@ +"use client" + +import * as React from "react" +import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( +
+ ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index efdfce9..5695d23 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -1,4 +1,5 @@ import { + Ellipsis, ExternalLink, File, Mail, @@ -54,6 +55,7 @@ const LinkedInSVG = () => ( ) const icons: IconsMap = { + ellipsis: Ellipsis, send: Send, mail: Mail, menu: Menu,