diff --git a/bun.lockb b/bun.lockb index d065144..4e87010 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index b94a41d..612e7f6 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.1.0", "d3-selection": "^3.0.0", + "embla-carousel-react": "^8.5.2", "framer-motion": "^12.6.0", "lucide-react": "^0.484.0", "next-themes": "^0.4.6", diff --git a/public/imageography-1.png b/public/imageography-1.png new file mode 100644 index 0000000..9890a66 Binary files /dev/null and b/public/imageography-1.png differ diff --git a/public/imageography-2.png b/public/imageography-2.png new file mode 100644 index 0000000..02a9948 Binary files /dev/null and b/public/imageography-2.png differ diff --git a/public/imageography-3.png b/public/imageography-3.png new file mode 100644 index 0000000..464bd7a Binary files /dev/null and b/public/imageography-3.png differ diff --git a/public/imageography-4.png b/public/imageography-4.png new file mode 100644 index 0000000..54ee0c2 Binary files /dev/null and b/public/imageography-4.png differ diff --git a/public/minified-js.jpg b/public/minified-js.jpg deleted file mode 100644 index c277563..0000000 Binary files a/public/minified-js.jpg and /dev/null differ diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 0000000..09cf122 --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,239 @@ +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, +}; diff --git a/src/constants/project-constants.ts b/src/constants/project-constants.ts index 4a9d4df..f697888 100644 --- a/src/constants/project-constants.ts +++ b/src/constants/project-constants.ts @@ -2,13 +2,15 @@ export type ProjectLink = { url: string; }; +export type Detail = { + headline: string; + body: Detail[] | string; +}; + export type Project = { title: string; description: string; - details: Array<{ - headline: string; - body: string; - }>; + details: Detail[]; techStack: string[]; href: string; github?: string; @@ -22,10 +24,55 @@ export const PROJECTS: Project[] = [ { title: "Imageography", description: "A mobile application built on the React-Native framework.", - techStack: ["React-Native", "Redux", "TypeScript", "Firebase"], + techStack: ["React-Native", "Expo", "Redux", "TypeScript", "Firebase"], href: "/projects/imageography", github: "https://github.com/haypho/imageography", - details: [], + details: [ + { + headline: "Overview", + body: ` + My first mobile app built on Expo (i.e., React Native) and Firebase that allows + users to store potential photoshoot and modeling locations on a map. + The app was originally published to the Apple App Store and Google Play Store. + It has since been removed as I work towards launching a new app! + `, + }, + { + headline: "Learnings", + body: [ + { + headline: "Learning #1: A good app name makes all the difference", + body: ` + Creating an app name gives you the chance to be creative and have fun + discussions with friends and family. The app name "Imageography" came + from a play on words (i.e., "image" and "geography"). Although I thought + the app name was pretty clever, I quickly realized how bad the name actually + was when trying to share it with potential users. Five sylables, difficult to + spell, too long to search in the app store, too similar to other "image" based + apps, etc. The list keeps going for this mistake. I probably should have + realized this issue sooner in conversations, but most of my time was spent + actually building the thing! + `, + }, + { + headline: "Learning #2: Online vs Offline Apps", + body: ` + The plan from the beginning was to allow the users to store locations on their + devices. I compromised with online capabilities to reduce cost initially, but + little did I know that this would be the downfall of the app almost instantly. + In today's world, everything is online. I know this, you know this, and the + users definitely expected this. When I lauched V1 of the app, the very first + question every user had for me was "How do I share this location with my friends?" + with the second one being "How do I get these markers on my other device?". The + expectation for online presence was overwhelming... I attempted to pivot to an + online version of the app via Firebase, but I was working on this project during + the nights/weekends. Each day I worked on a new online feature, I could see the + users leaving the app. This ultimately led me to remove the app from the app store. + `, + }, + ], + }, + ], }, { title: "React-Native Web Cache", diff --git a/src/features/projects/project-detail.tsx b/src/features/projects/project-detail.tsx new file mode 100644 index 0000000..c1ca84b --- /dev/null +++ b/src/features/projects/project-detail.tsx @@ -0,0 +1,35 @@ +import { Detail } from "@/constants"; +import { cn } from "@/lib/utils"; +import { ReactNode } from "react"; + +export type ProjectDetailProps = Detail & { + heading?: number; + children?: ReactNode; +}; + +export function ProjectDetail({ heading = 2, ...props }: ProjectDetailProps) { + return ( + <> +

+ {props.headline} +

+ {typeof props.body === "string" ? ( +

{props.body}

+ ) : ( + props.body.map((detail, index) => ( + + )) + )} + {props.children} + + ); +} diff --git a/src/features/projects/project-details.tsx b/src/features/projects/project-details.tsx index 4b5c7e1..cbe5a7a 100644 --- a/src/features/projects/project-details.tsx +++ b/src/features/projects/project-details.tsx @@ -1,10 +1,16 @@ import { Project } from "@/constants"; import { ProjectDetailsTechStack } from "./project-details-tech-stack"; -import React from "react"; +import React, { ReactNode } from "react"; import { Construction } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faGithub } from "@fortawesome/free-brands-svg-icons"; +import { ProjectDetail } from "./project-detail"; export type ProjectDetailsProps = { project?: Project; + children?: ReactNode; }; export const ProjectDetails = (props: ProjectDetailsProps) => { @@ -14,15 +20,27 @@ export const ProjectDetails = (props: ProjectDetailsProps) => { return (
-

{props.project.title}

+
+

{props.project.title}

+ {props.project.github && ( + + )} +
- {props.project.details.map((detail, index) => ( -
-

{detail.headline}

-

{detail.body}

-
- ))} + {props.project.details.map((detail, index) => + index === 0 ? ( + + {props.children} + + ) : ( + + ), + )} {props.project.details.length === 0 && (
diff --git a/src/pages/projects/imageography.tsx b/src/pages/projects/imageography.tsx index b722020..2a56561 100644 --- a/src/pages/projects/imageography.tsx +++ b/src/pages/projects/imageography.tsx @@ -4,13 +4,40 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel"; import { PROJECTS } from "@/constants"; import { AppHeader } from "@/features/app-header/app-header"; import { ProjectDetails } from "@/features/projects/project-details"; import { House } from "lucide-react"; +import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; +const SCREENSHOTS = [ + { + src: "/info/imageography-1.png", + alt: "Imageography map screen", + }, + { + src: "/info/imageography-2.png", + alt: "Imageography new map marker dialog", + }, + { + src: "/info/imageography-3.png", + alt: "Imageography map marker list screen", + }, + { + src: "/info/imageography-4.png", + alt: "Imageography edit map marker screen", + }, +]; + export default function ProjectsImageographyPage() { const pathname = usePathname(); const project = PROJECTS.find((p) => p.href === pathname); @@ -36,7 +63,22 @@ export default function ProjectsImageographyPage() {
- + + + + {SCREENSHOTS.map(({ src, alt }) => ( + + {alt} + + ))} + + + + +
); diff --git a/src/styles/global.css b/src/styles/global.css index 348eeac..138cbc2 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -130,6 +130,6 @@ body { @apply border-border outline-ring/50; } body { - @apply bg-background text-foreground; + @apply bg-sidebar text-foreground; } }