From 3f5b2843745a6602ea927acf9de51513a6eabd79 Mon Sep 17 00:00:00 2001 From: Johnson Mao Date: Fri, 25 Oct 2024 22:54:04 +0800 Subject: [PATCH 1/7] feat: resize observer hook --- components/shared/Carousel/v2/Carousel.tsx | 12 +++--- hooks/useResizeObserver.ts | 48 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 hooks/useResizeObserver.ts diff --git a/components/shared/Carousel/v2/Carousel.tsx b/components/shared/Carousel/v2/Carousel.tsx index 6eba41f4..38568b26 100644 --- a/components/shared/Carousel/v2/Carousel.tsx +++ b/components/shared/Carousel/v2/Carousel.tsx @@ -1,5 +1,6 @@ -import { CSSProperties, FC, Key, useEffect, useRef, useState } from "react"; +import { CSSProperties, FC, Key, useRef, useState } from "react"; import Icon from "@/components/shared/Icon"; +import useResizeObserver from "@/hooks/useResizeObserver"; interface CarouselProps> { items: Item[]; @@ -16,6 +17,11 @@ export default function Carousel>({ const [maxWidth, setMaxWidth] = useState(0); const carouselRef = useRef(null); + useResizeObserver({ + elementRef: carouselRef, + callback: (rect) => setMaxWidth(rect.contentRect.width), + }); + const handleChangePage = (action: "prev" | "next") => () => { const maxIndex = items.length - 1; @@ -40,10 +46,6 @@ export default function Carousel>({ "p-2.5 shrink-0 bg-white/4 shadow-default rounded-2xl"; const buttonIconClassName = "stroke-white w-6 h-6 pointer-events-none"; - useEffect(() => { - setMaxWidth(carouselRef.current?.clientWidth || 0); - }, []); - return (
-
-
    - {Array.isArray(items) && - items.map((item) => ( -
  • - -
  • - ))} -
+ +
+ + + + +
- -
+ ); } diff --git a/components/shared/Carousel/v2/Carousel.type.ts b/components/shared/Carousel/v2/Carousel.type.ts new file mode 100644 index 00000000..b4763513 --- /dev/null +++ b/components/shared/Carousel/v2/Carousel.type.ts @@ -0,0 +1,50 @@ +import { Dispatch, FC, Key, PropsWithChildren } from "react"; +import { IconName } from "@/components/shared/Icon"; + +export type TItem = Record & { id: Key }; + +export type CarouselItemProps = Item & { + showIndex: number; +}; + +export interface ICarouselContext { + items: Item[]; + showIndex: number; + renderKey: (item: Item) => Key; + Component: FC>; +} + +export interface CarouselProps { + items: ICarouselContext["items"]; + Component: ICarouselContext["Component"]; + renderKey?: ICarouselContext["renderKey"]; +} + +export const enum CarouselActionType { + Previous = "previous", + Next = "next", + SetPage = "setPage", + UpdateItems = "updateItems", +} + +export type TCarouselAction = + | { type: CarouselActionType.Previous } + | { type: CarouselActionType.Next } + | { type: CarouselActionType.SetPage; payload: { page: number } } + | { type: CarouselActionType.UpdateItems; payload: { items: Item[] } }; + +export type TCarouselDispatchContext = Dispatch< + TCarouselAction +>; + +export interface CarouselProviderProps + extends PropsWithChildren { + items: ICarouselContext["items"]; + Component: ICarouselContext["Component"]; + renderKey?: ICarouselContext["renderKey"]; +} + +export interface CarouselButtonConfig { + iconName: IconName; + actionType: CarouselActionType.Previous | CarouselActionType.Next; +} diff --git a/components/shared/Carousel/v2/CarouselButton.tsx b/components/shared/Carousel/v2/CarouselButton.tsx new file mode 100644 index 00000000..455e86b2 --- /dev/null +++ b/components/shared/Carousel/v2/CarouselButton.tsx @@ -0,0 +1,42 @@ +import Icon from "@/components/shared/Icon"; +import { useCarouselDispatch } from "./CarouselContext"; +import { CarouselActionType, CarouselButtonConfig } from "./Carousel.type"; + +export const enum CarouselButtonType { + Previous = "previous", + Next = "next", +} + +interface CarouselButtonProps { + type: CarouselButtonType | `${CarouselButtonType}`; +} + +const configs: Record = { + [CarouselButtonType.Previous]: { + iconName: "NavArrowLeft", + actionType: CarouselActionType.Previous, + }, + [CarouselButtonType.Next]: { + iconName: "NavArrowRight", + actionType: CarouselActionType.Next, + }, +}; + +export default function CarouselButton({ + type, +}: Readonly) { + const dispatch = useCarouselDispatch(); + const config = configs[type]; + return ( + + ); +} diff --git a/components/shared/Carousel/v2/CarouselContext.tsx b/components/shared/Carousel/v2/CarouselContext.tsx new file mode 100644 index 00000000..cf4bb71c --- /dev/null +++ b/components/shared/Carousel/v2/CarouselContext.tsx @@ -0,0 +1,114 @@ +import { createContext, useContext, useEffect, useReducer } from "react"; +import { + CarouselActionType, + CarouselProviderProps, + ICarouselContext, + TCarouselAction, + TCarouselDispatchContext, + TItem, +} from "./Carousel.type"; + +const CarouselContext = createContext(null); +const CarouselDispatchContext = createContext( + null +); + +const initialState: ICarouselContext = { + items: [], + showIndex: 0, + renderKey: (item) => item.id, + Component: (props) => <>{props.id}, +}; + +const calcPage = ( + page: number, + state: ICarouselContext +): number => { + const maxPage = state.items.length - 1; + if (page > maxPage) return 0; + if (page < 0) return maxPage; + return page; +}; + +const reducer = ( + state: ICarouselContext, + action: TCarouselAction +): ICarouselContext => { + switch (action.type) { + case CarouselActionType.Previous: { + return { + ...state, + showIndex: calcPage(state.showIndex - 1, state), + }; + } + case CarouselActionType.Next: { + return { + ...state, + showIndex: calcPage(state.showIndex + 1, state), + }; + } + case CarouselActionType.SetPage: { + return { + ...state, + showIndex: calcPage(action.payload.page, state), + }; + } + case CarouselActionType.UpdateItems: { + return { + ...state, + items: action.payload.items, + }; + } + default: + throw new Error(); + } +}; + +export const useCarousel = () => { + const hook = useContext(CarouselContext); + if (!hook) { + throw new Error("useCarousel must be used within a CarouselProvider."); + } + return hook; +}; + +export const useCarouselDispatch = () => { + const hook = useContext(CarouselDispatchContext); + if (!hook) { + throw new Error( + "useCarouselDispatch must be used within a CarouselProvider." + ); + } + return hook; +}; + +export function CarouselProvider({ + children, + items, + Component, + renderKey = (item) => item.id, +}: CarouselProviderProps) { + const [state, dispatch] = useReducer(reducer, { + ...initialState, + items, + Component, + renderKey, + }); + + useEffect(() => { + dispatch({ + type: CarouselActionType.UpdateItems, + payload: { items }, + }); + }, [items]); + + return ( + + + {children} + + + ); +} diff --git a/components/shared/Carousel/v2/CarouselMain.tsx b/components/shared/Carousel/v2/CarouselMain.tsx new file mode 100644 index 00000000..9d12a16d --- /dev/null +++ b/components/shared/Carousel/v2/CarouselMain.tsx @@ -0,0 +1,59 @@ +import { + CSSProperties, + PropsWithChildren, + useLayoutEffect, + useRef, + useState, +} from "react"; +import useResizeObserver from "@/hooks/useResizeObserver"; +import { useCarousel } from "./CarouselContext"; +import useAutoReset from "@/hooks/useAutoReset"; +import { cn } from "@/lib/utils"; + +export default function CarouselMain({ children }: PropsWithChildren) { + const { showIndex, items, Component, renderKey } = useCarousel(); + const [carouselItemWidth, setCarouselItemWidth] = useState(0); + const carouselRef = useRef(null); + const [isAnimating, setIsAnimating] = useAutoReset(false, 150); + + useResizeObserver({ + elementRef: carouselRef, + callback: (rect) => setCarouselItemWidth(rect.contentRect.width), + }); + + useLayoutEffect(() => { + setIsAnimating(true); + }, [showIndex]); + + return ( +
+
    + {Array.isArray(items) && + items.map((item) => ( +
  • + +
  • + ))} +
+ {children} +
+ ); +} diff --git a/components/shared/Carousel/v2/CarouselPagination.tsx b/components/shared/Carousel/v2/CarouselPagination.tsx new file mode 100644 index 00000000..b60d5f4e --- /dev/null +++ b/components/shared/Carousel/v2/CarouselPagination.tsx @@ -0,0 +1,35 @@ +import { cn } from "@/lib/utils"; +import { useCarousel, useCarouselDispatch } from "./CarouselContext"; +import { CarouselActionType } from "./Carousel.type"; + +export default function CarouselPagination() { + const { showIndex, items, renderKey } = useCarousel(); + const dispatch = useCarouselDispatch(); + + return ( +
    + {Array.isArray(items) && + items.map((item, index) => ( +
  • + +
  • + ))} +
+ ); +} From decb2b0922f769581921bbb1814c22e56f89b2f0 Mon Sep 17 00:00:00 2001 From: Johnson Mao Date: Sat, 26 Oct 2024 16:36:27 +0800 Subject: [PATCH 5/7] refactor: home page and layout --- .../lobby/CreateRoomModal/CreateRoomModal.tsx | 8 +- components/shared/Header.tsx | 2 +- pages/index.tsx | 76 ++++++++++++++++--- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/components/lobby/CreateRoomModal/CreateRoomModal.tsx b/components/lobby/CreateRoomModal/CreateRoomModal.tsx index 26631a86..cfe105d5 100644 --- a/components/lobby/CreateRoomModal/CreateRoomModal.tsx +++ b/components/lobby/CreateRoomModal/CreateRoomModal.tsx @@ -99,7 +99,13 @@ export default function CreateRoomModal() { return ( <> - + diff --git a/pages/index.tsx b/pages/index.tsx index 91ae05fb..c9f0efca 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,5 +1,6 @@ import { GetStaticProps } from "next"; import { useRouter } from "next/router"; +import Link from "next/link"; import Image from "next/image"; import { ReactEventHandler, useEffect, useState } from "react"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; @@ -16,7 +17,9 @@ import { GameType, getAllGamesEndpoint } from "@/requests/games"; import useRequest from "@/hooks/useRequest"; import useUser from "@/hooks/useUser"; import { useToast } from "@/components/shared/Toast"; +import Icon from "@/components/shared/Icon"; import gameDefaultCoverImg from "@/public/images/game-default-cover.png"; +import { CarouselItemProps } from "@/components/shared/Carousel/v2/Carousel.type"; const onImageError: ReactEventHandler = (e) => { if (e.target instanceof HTMLImageElement) { @@ -31,13 +34,14 @@ function CarouselCard({ createdOn, maxPlayers, minPlayers, -}: Readonly) { +}: Readonly>) { // 待重構將邏輯統一管理 const { fetch } = useRequest(); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); const toast = useToast(); const { updateRoomId } = useUser(); + const [open, setOpen] = useState(false); const handleFastJoin = async () => { try { @@ -63,7 +67,10 @@ function CarouselCard({ }; return ( -
+
setOpen(false)} + >
- +
+ + {open && ( +
setOpen(false)} + > +
    +
  • + + 加入現有房間 + +
  • +
  • + +
  • +
  • + +
  • +
+
+ )} +
Game Name
{name}
-
4.8 * * * * * (66)
-
@@ -221,11 +277,7 @@ export default function Home() { />
- item.id} - Component={CarouselCard} - /> +
Date: Sat, 26 Oct 2024 16:55:45 +0800 Subject: [PATCH 6/7] chore: fix storybook and sonar cloud issue --- components/shared/Carousel/v2/Carousel.stories.tsx | 7 +++++-- components/shared/Carousel/v2/CarouselContext.tsx | 2 +- pages/index.tsx | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/components/shared/Carousel/v2/Carousel.stories.tsx b/components/shared/Carousel/v2/Carousel.stories.tsx index 27cc64a7..b77a2e62 100644 --- a/components/shared/Carousel/v2/Carousel.stories.tsx +++ b/components/shared/Carousel/v2/Carousel.stories.tsx @@ -21,8 +21,11 @@ const meta: Meta = { }, ], args: { - renderKey: (props: any) => props.name, - items: [{ name: "TEST 1" }, { name: "TEST 2" }, { name: "TEST 3" }], + items: [ + { id: 1, name: "TEST 1" }, + { id: 2, name: "TEST 2" }, + { id: 3, name: "TEST 3" }, + ], Component: Card, }, argTypes: { diff --git a/components/shared/Carousel/v2/CarouselContext.tsx b/components/shared/Carousel/v2/CarouselContext.tsx index cf4bb71c..8a4c8d58 100644 --- a/components/shared/Carousel/v2/CarouselContext.tsx +++ b/components/shared/Carousel/v2/CarouselContext.tsx @@ -87,7 +87,7 @@ export function CarouselProvider({ items, Component, renderKey = (item) => item.id, -}: CarouselProviderProps) { +}: Readonly>) { const [state, dispatch] = useReducer(reducer, { ...initialState, items, diff --git a/pages/index.tsx b/pages/index.tsx index c9f0efca..52714d2a 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -70,6 +70,7 @@ function CarouselCard({
setOpen(false)} + role="menuitem" >
setOpen(false)} + role="menuitem" >
  • From f9bea0f4026c7db7da8f8c5e10576b6f71a993d9 Mon Sep 17 00:00:00 2001 From: Johnson Mao Date: Sat, 26 Oct 2024 19:57:04 +0800 Subject: [PATCH 7/7] fix: change page animation bug --- .../lobby/CreateRoomModal/CreateRoomModal.tsx | 3 +- .../shared/Carousel/v2/Carousel.type.ts | 1 + .../shared/Carousel/v2/CarouselMain.tsx | 22 +++--- hooks/useAutoReset.ts | 19 +++-- pages/index.tsx | 73 ++++++++++--------- pages/login.tsx | 16 ++-- 6 files changed, 70 insertions(+), 64 deletions(-) diff --git a/components/lobby/CreateRoomModal/CreateRoomModal.tsx b/components/lobby/CreateRoomModal/CreateRoomModal.tsx index cfe105d5..2cbf5857 100644 --- a/components/lobby/CreateRoomModal/CreateRoomModal.tsx +++ b/components/lobby/CreateRoomModal/CreateRoomModal.tsx @@ -18,7 +18,7 @@ const initialRoomFormState = { maxPlayers: 0, }; -export default function CreateRoomModal() { +export default function CreateRoomModal({ tabIndex }: { tabIndex: number }) { const [showThisModal, setshowThisModal] = useState(false); const [showGameListModal, setShowGameListModal] = useState(false); const [gameList, setGameList] = useState([]); @@ -102,6 +102,7 @@ export default function CreateRoomModal() { - {open && ( -
    setOpen(false)} - role="menuitem" - > -
      -
    • - - 加入現有房間 - -
    • -
    • - -
    • -
    • - -
    • -
    -
    - )} +
    +
      +
    • + + 加入現有房間 + +
    • +
    • + +
    • +
    • + +
    • +
    +
diff --git a/pages/login.tsx b/pages/login.tsx index 24413b79..1ba3f479 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -8,10 +8,11 @@ import { import { useRouter } from "next/router"; import { GetStaticProps } from "next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import Link from "next/link"; import ButtonV2, { ButtonVariant } from "@/components/shared/Button/v2"; import Cover from "@/components/shared/Cover"; -import IconV2, { IconName } from "@/components/shared/Icon"; +import Icon, { IconName } from "@/components/shared/Icon"; import useAuth from "@/hooks/context/useAuth"; import useUser from "@/hooks/useUser"; @@ -20,9 +21,7 @@ import { LoginType } from "@/requests/auth"; import { NextPageWithProps } from "./_app"; import { BoxFancy } from "@/components/shared/BoxFancy"; -import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { cn } from "@/lib/utils"; const LoginMethods: { text: string; type: LoginType; icon: IconName }[] = [ { text: "Google 帳號登入", type: LoginType.GOOGLE, icon: "Google" }, @@ -67,16 +66,11 @@ const Login: NextPageWithProps = () => { return LoginMethods.map(({ text, type, icon }) => ( onLoginClick(e, type)} > + {text} @@ -102,7 +96,7 @@ const Login: NextPageWithProps = () => {

) : null}

- + 遊戲微服務大平台

{!bye ? (