diff --git a/frontend/website/package-lock.json b/frontend/website/package-lock.json index ffae0b8..9083b2c 100644 --- a/frontend/website/package-lock.json +++ b/frontend/website/package-lock.json @@ -8,6 +8,7 @@ "name": "website", "version": "0.0.0", "dependencies": { + "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-slot": "^1.1.2", @@ -15,6 +16,7 @@ "@types/node": "^22.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.5.2", "lucide-react": "^0.476.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -1036,6 +1038,32 @@ "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", "license": "MIT" }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.3.tgz", + "integrity": "sha512-Paen00T4P8L8gd9bNsRMw7Cbaz85oxiv+hzomsRZgFm2byltPFDtfcoqlWJ8GyZlIBWgLssJlzLCnKU0G0302g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", @@ -2568,6 +2596,34 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.5.2.tgz", + "integrity": "sha512-xQ9oVLrun/eCG/7ru3R+I5bJ7shsD8fFwLEY7yPe27/+fDHCNj0OT5EoG5ZbFyOxOcG6yTwW8oTz/dWyFnyGpg==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.5.2.tgz", + "integrity": "sha512-Tmx+uY3MqseIGdwp0ScyUuxpBgx5jX1f7od4Cm5mDwg/dptEiTKf9xp6tw0lZN2VA9JbnVMl/aikmbc53c6QFA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.5.2", + "embla-carousel-reactive-utils": "8.5.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.5.2.tgz", + "integrity": "sha512-QC8/hYSK/pEmqEdU1IO5O+XNc/Ptmmq7uCB44vKplgLKhB/l0+yvYx0+Cv0sF6Ena8Srld5vUErZkT+yTahtDg==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.5.2" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", diff --git a/frontend/website/package.json b/frontend/website/package.json index 98519ee..9e42121 100644 --- a/frontend/website/package.json +++ b/frontend/website/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-slot": "^1.1.2", @@ -17,6 +18,7 @@ "@types/node": "^22.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.5.2", "lucide-react": "^0.476.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/frontend/website/src/App.tsx b/frontend/website/src/App.tsx index acedca7..80c50b2 100644 --- a/frontend/website/src/App.tsx +++ b/frontend/website/src/App.tsx @@ -5,7 +5,7 @@ import About from './components/About'; import Projects from './components/Projects'; import Contact from './components/Contact'; import Navbar04Page from './components/navbar-04/navbar-04'; - +import Testimonial06 from './components/testimonial-06/testimonial-06'; function App() { return (
@@ -13,6 +13,7 @@ function App() { +
); diff --git a/frontend/website/src/components/Hero.tsx b/frontend/website/src/components/Hero.tsx index 95d695a..52732ec 100644 --- a/frontend/website/src/components/Hero.tsx +++ b/frontend/website/src/components/Hero.tsx @@ -7,9 +7,8 @@ const Hero = () => {

Craig Macfadyen

- Data Scientist with experience in both research - and consulting roles. Expertise in Computer Vision and Large Language Models. Check out what - I can do and get in touch if you have any questions or you'd like to work together! + Data Scientist with experience in consulting and research. Expertise in Computer Vision and Large Language Models. Take a look at what + I can do for your business and get in touch if you have any questions or you'd like to work together!

diff --git a/frontend/website/src/components/testimonial-06/testimonial-06.tsx b/frontend/website/src/components/testimonial-06/testimonial-06.tsx new file mode 100644 index 0000000..fcd3e26 --- /dev/null +++ b/frontend/website/src/components/testimonial-06/testimonial-06.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + Carousel, + CarouselApi, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel"; +import { cn } from "@/lib/utils"; +import { useEffect, useState } from "react"; + +const testimonials = [ + { + id: 1, + name: "Steven Adair", + designation: "Director", + company: "Managing Utilities Ltd", + testimonial: + "What set Craig apart was his ability to understand our business challenges and deliver a solution that worked for us. He didn't just build a model, he delivered a system our team could actually use." + }, +]; +const Testimonial06 = () => { + const [api, setApi] = useState(); + const [current, setCurrent] = useState(0); + const [count, setCount] = useState(0); + + useEffect(() => { + if (!api) { + return; + } + + setCount(api.scrollSnapList().length); + setCurrent(api.selectedScrollSnap() + 1); + + api.on("select", () => { + setCurrent(api.selectedScrollSnap() + 1); + }); + }, [api]); + + return ( +
+
+

+ Testimonials +

+
+ + + {testimonials.map((testimonial) => ( + + + + ))} + + + + +
+ {Array.from({ length: count }).map((_, index) => ( +
+
+
+
+ ); +}; + +const TestimonialCard = ({ + testimonial, +}: { + testimonial: (typeof testimonials)[number]; +}) => ( +
+
+
+
+
+ + + {testimonial.name.charAt(0)} + + +
+

{testimonial.name}

+

{testimonial.designation}

+
+
+
+

+ "{testimonial.testimonial}" +

+
+ + + {testimonial.name.charAt(0)} + + +
+

{testimonial.name}

+

{testimonial.designation}

+

{testimonial.company}

+
+
+
+
+
+); + +export default Testimonial06; diff --git a/frontend/website/src/components/ui/avatar.tsx b/frontend/website/src/components/ui/avatar.tsx new file mode 100644 index 0000000..b7224f0 --- /dev/null +++ b/frontend/website/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/frontend/website/src/components/ui/carousel.tsx b/frontend/website/src/components/ui/carousel.tsx new file mode 100644 index 0000000..0e05a77 --- /dev/null +++ b/frontend/website/src/components/ui/carousel.tsx @@ -0,0 +1,241 @@ +"use client" + +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-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 +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + 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} +
+
+ ) +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel() + + return ( +
+ ) +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +}