diff --git a/index.html b/index.html index 68e10dc..6b4e8af 100644 --- a/index.html +++ b/index.html @@ -5,20 +5,9 @@ - - + + + diff --git a/package.json b/package.json index 9a14df8..bd66785 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1", "@tanstack/react-query": "^5.59.20", "@tanstack/react-query-devtools": "^5.60.5", @@ -22,6 +23,7 @@ "clsx": "^2.1.1", "echarts": "^5.5.1", "echarts-wordcloud": "^2.1.0", + "embla-carousel-react": "^8.5.1", "lucide-react": "^0.456.0", "next-themes": "^0.4.3", "postcss": "^8.4.47", diff --git a/public/ogImage.png b/public/ogImage.png new file mode 100644 index 0000000..e1ed79c Binary files /dev/null and b/public/ogImage.png differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/images/onboard1.png b/src/assets/images/onboard1.png new file mode 100644 index 0000000..0d7f282 Binary files /dev/null and b/src/assets/images/onboard1.png differ diff --git a/src/assets/images/onboard2.png b/src/assets/images/onboard2.png new file mode 100644 index 0000000..d9ccbdc Binary files /dev/null and b/src/assets/images/onboard2.png differ diff --git a/src/assets/images/onboard3.png b/src/assets/images/onboard3.png new file mode 100644 index 0000000..70f3175 Binary files /dev/null and b/src/assets/images/onboard3.png differ diff --git a/src/assets/images/onboard4.png b/src/assets/images/onboard4.png new file mode 100644 index 0000000..1647bbd Binary files /dev/null and b/src/assets/images/onboard4.png differ diff --git a/src/components/pages/main/home/Calendar.tsx b/src/components/pages/main/home/Calendar.tsx index f77a554..1037627 100644 --- a/src/components/pages/main/home/Calendar.tsx +++ b/src/components/pages/main/home/Calendar.tsx @@ -55,7 +55,7 @@ const Calendar = ({ useEffect(() => { const calendar = generateCalendar(currentDate.getFullYear(), currentDate.getMonth()); setCalendarDays(calendar); - }, [calendarData, currentDate]); + }, [calendarData]); const generateCalendar = (year: number, month: number) => { const firstDayOfMonth = new Date(year, month, 1); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..36496a2 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 0000000..e9b0171 --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,240 @@ +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; +} + +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>( + ({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); + } +); +CarouselContent.displayName = "CarouselContent"; + +const CarouselItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const { orientation } = useCarousel(); + + return ( +
+ ); + } +); +CarouselItem.displayName = "CarouselItem"; + +const CarouselPrevious = React.forwardRef>( + ({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); + } +); +CarouselPrevious.displayName = "CarouselPrevious"; + +const CarouselNext = React.forwardRef>( + ({ 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/hooks/useInView.ts b/src/hooks/useInView.ts index edd9434..9bbbe40 100644 --- a/src/hooks/useInView.ts +++ b/src/hooks/useInView.ts @@ -17,8 +17,8 @@ const useInView = (threshold: number, onViewEscape?: () => vo if (entry.isIntersecting) { setIsInView(true); } else { - setIsInView(false); if (onViewEscape) onViewEscape(); + else setIsInView(false); } }); }, diff --git a/src/pages/main/Home.tsx b/src/pages/main/Home.tsx index 1ba1148..c83bce4 100644 --- a/src/pages/main/Home.tsx +++ b/src/pages/main/Home.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import Calendar from "@/components/pages/main/home/Calendar"; import useCalendarData from "@/hooks/query/useCalendarData"; +import Onboarding from "../user/Onboarding"; const STORAGE_KEY = "currentDate"; /** * 메인 화면 @@ -17,6 +18,16 @@ const Home = () => { return storedDate ? new Date(storedDate) : new Date(); }); const { calendarData, isActiveToday } = useCalendarData(currentDate); + const [showOnboarding, setShowOnboarding] = useState(false); + + useEffect(() => { + const hasVisited = localStorage.getItem("hasVisited"); + + if (!hasVisited) { + setShowOnboarding(true); + localStorage.setItem("hasVisited", "true"); + } + }, []); const navigate = useNavigate(); const onClickCreateDiary = () => { @@ -79,6 +90,7 @@ const Home = () => { bgColor="dark" />
+ {showOnboarding && setShowOnboarding(false)} />}
); }; diff --git a/src/pages/user/Onboarding.tsx b/src/pages/user/Onboarding.tsx new file mode 100644 index 0000000..067a5b7 --- /dev/null +++ b/src/pages/user/Onboarding.tsx @@ -0,0 +1,129 @@ +import Button from "@/components/common/Button/Button"; +import ScrollLayout from "@/components/Layout/ScrollLayout"; +import { Carousel, CarouselApi, CarouselContent, CarouselItem } from "@/components/ui/carousel"; +import { useEffect, useState } from "react"; +import img1 from "@/assets/images/onboard1.png"; +import img2 from "@/assets/images/onboard2.png"; +import img3 from "@/assets/images/onboard3.png"; +import img4 from "@/assets/images/onboard4.png"; +import useInView from "@/hooks/useInView"; +import { FADEINANIMATION } from "@/styles/animations"; + +interface OnboardingProps { + onClose: () => void; +} + +const Onboarding = ({ onClose }: OnboardingProps) => { + const [api, setApi] = useState(); + const { elementRef: ref1, isInView: inView1 } = useInView(0.7, () => {}); + const { elementRef: ref2, isInView: inView2 } = useInView(0.7, () => {}); + const { elementRef: ref3, isInView: inView3 } = useInView(0.7, () => {}); + const { elementRef: ref4, isInView: inView4 } = useInView(0.7, () => {}); + const [current, setCurrent] = useState(0); + + const handleSelect = () => { + if (!api) return; + const selectedIndex = api.selectedScrollSnap(); + setCurrent(selectedIndex); + }; + + useEffect(() => { + if (!api) return; + + api.on("select", handleSelect); + + return () => { + api.off("select", handleSelect); + }; + }, [api, handleSelect]); + + return ( + +
    + {Array.from({ length: 4 }, (_, index) => ( +
  1. + ))} +
+ + + +
+

날짜를 클릭해

+

+ 일기를 작성하거나 확인하세요 +

+
+ +
+ + +
+

+ 일기를 작성하면 AI가 감정을 +

+

+ 분석하고 관련 이미지를 생성합니다 +

+
+ +
+ + +
+

+ 다른 사람들의 일기를 통해 +

+

+ 새로운 이야기를 만나보세요 +

+
+ +
+ + +
+

+ 내 일기의 감정과 키워드를 분석한 +

+

+ 통계를 확인하세요 +

+
+ +
+
+
+