diff --git a/components/lobby/CreateRoomModal/CreateRoomModal.tsx b/components/lobby/CreateRoomModal/CreateRoomModal.tsx index 26631a86..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([]); @@ -99,7 +99,14 @@ export default function CreateRoomModal() { return ( <> - + = { interface BaseButtonProps { variant?: ButtonVariant | `${ButtonVariant}`; size?: ButtonSize; - icon?: ReactNode; - iconName?: IconName; - iconClassName?: string; } type ButtonProps = BaseButtonProps & React.ComponentPropsWithoutRef<"button">; @@ -50,24 +46,12 @@ type InnerButtonComponent = ( ref?: PolymorphicRef<"button"> ) => React.ReactElement | null; -const iconTypeClasses: Record = { - primary: - "stroke-primary-700 hover:stroke-primary-50 active:stroke-primary-50", - primaryTransparent: - "stroke-primary-700 hover:stroke-primary-700 active:stroke-primary-700", - secondary: "stroke-primary-200 disabled:stroke-grey-500", - highlight: "stroke-primary-50", -}; - const InteralButton: InnerButtonComponent = ( { variant = ButtonVariant.PRIMARY, size = ButtonSize.REGULAR, - icon, - iconName, disabled, className, - iconClassName, children, onClick, ...otherButtonAttributes @@ -91,10 +75,8 @@ const InteralButton: InnerButtonComponent = ( commonDisabledClasses, buttonTypeClasses[variant], buttonSizeClasses[size], - iconTypeClasses[variant], className ); - const iconClasses = cn("w-6 h-6 stroke-inherit", iconClassName); const borderGradientColor: BoxFancyBorderGradientVariant = variant === ButtonVariant.SECONDARY && !disabled ? "purple" : "none"; @@ -110,8 +92,7 @@ const InteralButton: InnerButtonComponent = ( disabled={disabled} {...otherButtonAttributes} > - {icon || (iconName && )} - {children} + {children} ); }; 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/Carousel.tsx b/components/shared/Carousel/v2/Carousel.tsx index 6eba41f4..2e03363c 100644 --- a/components/shared/Carousel/v2/Carousel.tsx +++ b/components/shared/Carousel/v2/Carousel.tsx @@ -1,90 +1,23 @@ -import { CSSProperties, FC, Key, useEffect, useRef, useState } from "react"; -import Icon from "@/components/shared/Icon"; +import CarouselButton from "./CarouselButton"; +import CarouselPagination from "./CarouselPagination"; +import CarouselMain from "./CarouselMain"; +import { CarouselProvider } from "./CarouselContext"; +import { CarouselProps, TItem } from "./Carousel.type"; -interface CarouselProps> { - items: Item[]; - renderKey: (item: Item) => Key; - Component: FC; -} - -export default function Carousel>({ +export default function Carousel({ items, renderKey, Component, }: Readonly>) { - const [showIndex, setShowIndex] = useState(0); - const [maxWidth, setMaxWidth] = useState(0); - const carouselRef = useRef(null); - - const handleChangePage = (action: "prev" | "next") => () => { - const maxIndex = items.length - 1; - - switch (action) { - case "prev": - setShowIndex((preIndex) => { - const newIndex = preIndex - 1; - return newIndex > -1 ? newIndex : maxIndex; - }); - break; - case "next": - setShowIndex((preIndex) => { - const newIndex = preIndex + 1; - return newIndex <= maxIndex ? newIndex : 0; - }); - break; - default: - } - }; - - const buttonClassName = - "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..ec14a1c2 --- /dev/null +++ b/components/shared/Carousel/v2/Carousel.type.ts @@ -0,0 +1,51 @@ +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; + index: 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..8a4c8d58 --- /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, +}: Readonly>) { + 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..b0794c22 --- /dev/null +++ b/components/shared/Carousel/v2/CarouselMain.tsx @@ -0,0 +1,63 @@ +import { + CSSProperties, + PropsWithChildren, + useCallback, + 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(true, 150); + + const handleResize = useCallback( + (rect: ResizeObserverEntry) => { + setIsAnimating(false); + setCarouselItemWidth(rect.contentRect.width); + }, + [setIsAnimating] + ); + + useResizeObserver({ + elementRef: carouselRef, + callback: handleResize, + }); + + return ( +
+
    + {Array.isArray(items) && + items.map((item, index) => ( +
  • + +
  • + ))} +
+ {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) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/components/shared/Header.tsx b/components/shared/Header.tsx index c85b730a..11fb22c9 100644 --- a/components/shared/Header.tsx +++ b/components/shared/Header.tsx @@ -55,7 +55,7 @@ export default function Header({ return (
diff --git a/hooks/useAutoReset.ts b/hooks/useAutoReset.ts new file mode 100644 index 00000000..4d871a30 --- /dev/null +++ b/hooks/useAutoReset.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +function useAutoReset(initialValue: T, resetDelayMs = 1000) { + const [internalValue, setInternalValue] = useState(initialValue); + const timerRef = useRef(); + + const clearTimer = useCallback(() => { + clearTimeout(timerRef.current); + }, []); + + const setValue = useCallback( + (newValue: T) => { + setInternalValue(newValue); + clearTimer(); + timerRef.current = setTimeout( + () => setInternalValue(initialValue), + resetDelayMs + ); + }, + [clearTimer, initialValue, resetDelayMs] + ); + + useEffect(() => { + return () => clearTimer(); + }, [clearTimer]); + + return [internalValue, setValue] as const; +} + +export default useAutoReset; diff --git a/hooks/useResizeObserver.ts b/hooks/useResizeObserver.ts new file mode 100644 index 00000000..e635396e --- /dev/null +++ b/hooks/useResizeObserver.ts @@ -0,0 +1,48 @@ +import { RefObject, useCallback, useEffect, useRef } from "react"; + +type ElementNode = Element | Element[] | null; + +interface UseResizeObserverProps { + elementRef: RefObject; + callback: (entries: ResizeObserverEntry) => void; +} + +export default function useResizeObserver({ + elementRef, + callback, +}: UseResizeObserverProps) { + const observerRef = useRef(); + const internalElementRef = useRef(); + const internalCallback = useCallback( + (entries: ResizeObserverEntry[]) => entries.forEach(callback), + [callback] + ); + const setInternalElementRef = useCallback( + (node: ElementNode) => { + observerRef.current?.disconnect(); + observerRef.current = new ResizeObserver(internalCallback); + + if (Array.isArray(node)) { + node.forEach((_node) => observerRef.current?.observe(_node)); + } else if (node) { + observerRef.current.observe(node); + } + + internalElementRef.current = node; + }, + [internalCallback] + ); + + useEffect(() => { + const element = elementRef?.current; + + if (element) setInternalElementRef(element); + + return () => observerRef.current?.disconnect(); + }, [elementRef, setInternalElementRef]); + + return { + observerRef, + setInternalElementRef, + }; +} diff --git a/pages/index.tsx b/pages/index.tsx index 91ae05fb..a3de4cfe 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"; @@ -8,7 +9,6 @@ import { AxiosError } from "axios"; import Button from "@/components/shared/Button/v2"; import CreateRoomModal from "@/components/lobby/CreateRoomModal"; import CarouselV2 from "@/components/shared/Carousel/v2"; -import FastJoinButton from "@/components/lobby/FastJoinButton"; import SearchBar from "@/components/shared/SearchBar"; import Tabs, { TabItemType } from "@/components/shared/Tabs"; import { fastJoinGameEndpoint } from "@/requests/rooms"; @@ -16,7 +16,10 @@ 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"; +import { cn } from "@/lib/utils"; const onImageError: ReactEventHandler = (e) => { if (e.target instanceof HTMLImageElement) { @@ -25,19 +28,23 @@ const onImageError: ReactEventHandler = (e) => { }; function CarouselCard({ + showIndex, + index, id, img, name, 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 tabIndex = index === showIndex ? 0 : -1; const handleFastJoin = async () => { try { @@ -63,7 +70,10 @@ function CarouselCard({ }; return ( -
+
setOpen(false)} + >
- +
+ +
+
    +
  • + + 加入現有房間 + +
  • +
  • + +
  • +
  • + +
  • +
+
+
Game Name
{name}
-
4.8 * * * * * (66)
-
@@ -221,11 +282,7 @@ export default function Home() { />
- item.id} - Component={CarouselCard} - /> +
{ return LoginMethods.map(({ text, type, icon }) => ( onLoginClick(e, type)} > + {text} @@ -102,7 +96,7 @@ const Login: NextPageWithProps = () => {

) : null}

- + 遊戲微服務大平台

{!bye ? (