diff --git a/packages/webapp/package.json b/packages/webapp/package.json index f9c07c9f..9e0db289 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -24,11 +24,13 @@ "lodash": "^4.17.21", "lucide-react": "^0.309.0", "next": "^13.5.6", + "next-themes": "^0.2.1", "next-transpile-modules": "^10.0.1", "react": "18.2.0", "react-dom": "18.2.0", "react-icons": "^4.11.0", "snarkjs": "^0.7.1", + "sonner": "^1.4.0", "tailwind-merge": "^2.2.0", "tailwindcss-animate": "^1.0.7", "viem": "^1.16.6", diff --git a/packages/webapp/src/actions/drawCard.ts b/packages/webapp/src/actions/drawCard.ts index f1bf42b5..bf536e77 100644 --- a/packages/webapp/src/actions/drawCard.ts +++ b/packages/webapp/src/actions/drawCard.ts @@ -54,6 +54,10 @@ export async function drawCard(args: DrawCardArgs): Promise { } } +/** Intentionally left blank to ignore any loading message. + * This function accepts a message parameter but does nothing with it. */ +function setLoading(_message: string|null|undefined) {} + async function drawCardImpl(args: DrawCardArgs): Promise { const gameID = getGameID() @@ -92,7 +96,7 @@ async function drawCardImpl(args: DrawCardArgs): Promise { const cards = getCards()! console.log(`drew card ${cards[selectedCard]}`) - args.setLoading("Generating draw proof ...") + setLoading("Generating draw proof ...") const tmpHandSize = privateInfo.handIndexes.indexOf(255) const initialHandSize = tmpHandSize < 0 @@ -138,7 +142,7 @@ async function drawCardImpl(args: DrawCardArgs): Promise { proof.proof_b, proof.proof_c ], - setLoading: args.setLoading + setLoading: setLoading }))) // TODO: this should be put in an optimistic store, before proof generation diff --git a/packages/webapp/src/components/cards/cardContainer.tsx b/packages/webapp/src/components/cards/cardContainer.tsx index d4c112fc..b473aaf4 100644 --- a/packages/webapp/src/components/cards/cardContainer.tsx +++ b/packages/webapp/src/components/cards/cardContainer.tsx @@ -12,12 +12,14 @@ interface BaseCardProps { className?: string handHovered?: boolean placement: CardPlacement + cardGlow?: boolean } const CardContainer: React.FC = ({ id, handHovered, placement, + cardGlow }) => { const { attributes, @@ -46,6 +48,7 @@ const CardContainer: React.FC = ({ handHovered={handHovered} isDragging={isDragging} ref={setNodeRef} + cardGlow={cardGlow} /> ) case CardPlacement.BOARD: diff --git a/packages/webapp/src/components/cards/handCard.tsx b/packages/webapp/src/components/cards/handCard.tsx index 354335bf..bfcf3839 100644 --- a/packages/webapp/src/components/cards/handCard.tsx +++ b/packages/webapp/src/components/cards/handCard.tsx @@ -6,10 +6,11 @@ interface HandCardProps { id: number handHovered?: boolean isDragging: boolean + cardGlow?: boolean } const HandCard = forwardRef( - ({ id, isDragging, handHovered }, ref) => { + ({ id, isDragging, handHovered, cardGlow }, ref) => { const [ cardHover, setCardHover ] = useState(false) const [ isDetailsVisible, setIsDetailsVisible ] = useState(false) const showingDetails = isDetailsVisible && !isDragging @@ -50,7 +51,7 @@ const HandCard = forwardRef( className="pointer-events-none rounded-xl border select-none" style={{ boxShadow: - cardHover && !isDetailsVisible ? "0 0 10px 2px gold" : "none", // Adds golden glow when hovered + (cardHover && !isDetailsVisible) || cardGlow ? "0 0 10px 2px gold" : "none", // Adds golden glow when hovered }} /> {showingDetails && ( diff --git a/packages/webapp/src/components/hand.tsx b/packages/webapp/src/components/hand.tsx index eaa2dcad..c3fddfd2 100644 --- a/packages/webapp/src/components/hand.tsx +++ b/packages/webapp/src/components/hand.tsx @@ -20,10 +20,15 @@ const Hand = ({ setLoading: (label: string | null) => void cancellationHandler: CancellationHandler }) => { - const [ isFocused, setIsFocused ] = useState(false) - const scrollWrapperRef = useRef() - const { showLeftArrow, scrollLeft, showRightArrow, scrollRight } = - useScrollBox(scrollWrapperRef) + const [isFocused, setIsFocused] = useState(false) + const scrollWrapperRef = useRef(null) + const { + showLeftArrow, + scrollLeft, + showRightArrow, + scrollRight, + isLastCardGlowing, + } = useScrollBox(scrollWrapperRef, cards) const { setNodeRef } = useSortable({ id: CardPlacement.HAND, @@ -44,7 +49,9 @@ const Hand = ({ return (
{ setIsFocused(true) @@ -74,6 +81,7 @@ const Hand = ({
))} @@ -94,4 +102,4 @@ const Hand = ({ ) } -export default Hand \ No newline at end of file +export default Hand diff --git a/packages/webapp/src/components/modals/globalErrorModal.tsx b/packages/webapp/src/components/modals/globalErrorModal.tsx index e030fe9a..20f43bf9 100644 --- a/packages/webapp/src/components/modals/globalErrorModal.tsx +++ b/packages/webapp/src/components/modals/globalErrorModal.tsx @@ -6,6 +6,7 @@ import { DialogTitle, } from "../ui/dialog" import { Button } from "src/components/ui/button" +import { useEffect, useState } from "react" /** * A modal displayed globally (setup in _app.tsx) whenever the errorConfig state is set to non-null. @@ -16,9 +17,14 @@ export const GlobalErrorModal = ({ config }: { config: ErrorConfig }) => { // UI. This is good practice as it lets the user figure out what happened. Really not a priority // at the moment, and the error should be systematically logged to the console instead, for // debugging purposes. + const [ open, setOpen ] = useState(false) + useEffect(() => { + if(config !== null && !open) setOpen(true) + else setOpen(false) + }, [config, open]) return ( - + {config.title} {config.message !== "" && ( diff --git a/packages/webapp/src/components/ui/dialog.tsx b/packages/webapp/src/components/ui/dialog.tsx index ca4273b5..3e91eb98 100644 --- a/packages/webapp/src/components/ui/dialog.tsx +++ b/packages/webapp/src/components/ui/dialog.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from "lucide-react" diff --git a/packages/webapp/src/components/ui/sonner.tsx b/packages/webapp/src/components/ui/sonner.tsx new file mode 100644 index 00000000..da6a59a6 --- /dev/null +++ b/packages/webapp/src/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +// ref: https://ui.shadcn.com/docs/components/sonner +// docs: https://sonner.emilkowal.ski/ +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/packages/webapp/src/hooks/useScrollBox.ts b/packages/webapp/src/hooks/useScrollBox.ts index 17851425..c1c45fc0 100644 --- a/packages/webapp/src/hooks/useScrollBox.ts +++ b/packages/webapp/src/hooks/useScrollBox.ts @@ -1,13 +1,18 @@ -import { useState, useEffect, useCallback } from "react" +import { useState, useEffect, useCallback, RefObject } from "react" import throttle from "lodash/throttle" +import { toast } from "sonner" const timing = (1 / 60) * 1000 -const decay = (v: any) => -0.1 * ((1 / timing) ^ 4) + v -function useScrollBox(scrollRef: any) { - const [lastScrollX, setLastScrollX] = useState(0) - const [showLeftArrow, setShowLeftArrow] = useState(false) - const [showRightArrow, setShowRightArrow] = useState(false) +function useScrollBox(scrollRef: RefObject, cards: readonly bigint[] | null) { + // Stores the last horizontal scroll position. + const [ lastScrollX, setLastScrollX ] = useState(0) + + // Determines the visibility of navigation arrows based on scroll position. + const [ showLeftArrow, setShowLeftArrow ] = useState(false) + const [ showRightArrow, setShowRightArrow ] = useState(false) + + const [ isLastCardGlowing, setIsLastCardGlowing ] = useState(false) const scrollWrapperCurrent = scrollRef.current @@ -15,6 +20,7 @@ function useScrollBox(scrollRef: any) { const scrollAmount = 2 * cardWidth const duration = 300 + /** Checks and updates the arrow visibility states based on the scroll position. */ const checkArrowsVisibility = () => { if (!scrollRef.current) return const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current @@ -22,7 +28,9 @@ function useScrollBox(scrollRef: any) { setShowRightArrow(scrollLeft < scrollWidth - clientWidth) } - const smoothScroll = (target: number) => { + /** Performs a smooth scrolling animation to a specified target position. + * Accepts a target scroll position and an optional callback to execute after completion. */ + const smoothScroll = useCallback((target: number, callback?: () => void) => { if (!scrollRef.current) return const start = scrollRef.current.scrollLeft @@ -32,15 +40,20 @@ function useScrollBox(scrollRef: any) { const now = Date.now() const time = Math.min(1, (now - startTime) / duration) - scrollRef.current.scrollLeft = start + time * (target - start) + scrollRef.current!.scrollLeft = start + time * (target - start) - if (time < 1) requestAnimationFrame(animateScroll) - else checkArrowsVisibility() + if (time < 1) { + requestAnimationFrame(animateScroll) + } else { + checkArrowsVisibility() + if (callback) callback() // Execute callback after the scroll animation completes + } } requestAnimationFrame(animateScroll) - } + }, []) + /** Scrolls the container a fixed distance to the left or right with animation. */ const scrollLeft = () => { if (!scrollRef.current) return const target = Math.max(0, scrollRef.current.scrollLeft - scrollAmount) @@ -58,6 +71,7 @@ function useScrollBox(scrollRef: any) { smoothScroll(target) } + /** Throttled function to update the last horizontal scroll position, minimizing performance impact. */ const handleLastScrollX = useCallback( throttle((screenX) => { setLastScrollX(screenX) @@ -65,6 +79,7 @@ function useScrollBox(scrollRef: any) { [] ) + /** Handles the wheel event to adjust the scrollLeft property, enabling horizontal scrolling. */ const handleScroll = (e: WheelEvent) => { if (scrollRef.current) { // Adjust the scrollLeft property based on the deltaY value @@ -72,11 +87,34 @@ function useScrollBox(scrollRef: any) { } } + /** Responds to window resize events to update arrow visibility states. */ const handleResize = () => { setShowLeftArrow(true) setShowRightArrow(true) } + /** Smoothly scrolls to the rightmost end of the container, + * triggers a glow in the last card added. */ + const smoothScrollToRightThenLeft = useCallback(() => { + const element = scrollRef.current + if (!element) return + + const targetRight = element.scrollWidth - element.clientWidth + smoothScroll(targetRight, () => { + triggerLastCardGlow() + }) + }, [scrollRef]) + + const triggerLastCardGlow = useCallback(() => { + setIsLastCardGlowing(true) + // dismiss the toast displaying draw status + toast.dismiss("DRAW_CARD_TOAST") + setTimeout(() => { + setIsLastCardGlowing(false) + }, 2500) + }, []) + + /** Sets up and cleans up event listeners for resize, scroll, and wheel events. */ useEffect(() => { if (scrollRef.current) { checkArrowsVisibility() @@ -101,11 +139,23 @@ function useScrollBox(scrollRef: any) { } }, [scrollWrapperCurrent, handleLastScrollX, lastScrollX]) + // Detects changes in the `cards` array to trigger the pop-up effect and initiate smooth scrolling to highlight new content. + useEffect(() => { + if (cards && cards.length > 0) { + const timer = setTimeout(() => { + smoothScrollToRightThenLeft() + }, 3000) + + return () => clearTimeout(timer) + } + }, [cards, smoothScrollToRightThenLeft]) + return { showLeftArrow, scrollLeft, showRightArrow, scrollRight, + isLastCardGlowing, } } diff --git a/packages/webapp/src/pages/_app.tsx b/packages/webapp/src/pages/_app.tsx index 00a9c79b..b3c7c95d 100644 --- a/packages/webapp/src/pages/_app.tsx +++ b/packages/webapp/src/pages/_app.tsx @@ -20,6 +20,7 @@ import "src/styles/globals.css" import { useRouter } from "next/router" import { ComponentType, useEffect } from "react" import { Deck } from "src/store/types" +import { Toaster } from "src/components/ui/sonner" // ================================================================================================= @@ -50,6 +51,7 @@ const MyApp: AppType = ({ Component, pageProps }) => { {jotaiDebug()} + diff --git a/packages/webapp/src/pages/play.tsx b/packages/webapp/src/pages/play.tsx index 216ad5ff..12d42cf9 100644 --- a/packages/webapp/src/pages/play.tsx +++ b/packages/webapp/src/pages/play.tsx @@ -38,6 +38,7 @@ import { createPortal } from "react-dom" import useDragEvents from "src/hooks/useDragEvents" import CardContainer from "src/components/cards/cardContainer" import { Button } from "src/components/ui/button" +import { toast } from "sonner" const Play: FablePage = ({ isHydrated }) => { const [ gameID, setGameID ] = store.useGameID() @@ -52,12 +53,14 @@ const Play: FablePage = ({ isHydrated }) => { const [ hasVisitedBoard, visitBoard ] = store.useHasVisitedBoard() useEffect(visitBoard, [visitBoard, hasVisitedBoard]) + // state variables const [ loading, setLoading ] = useState(null) const [ hideResults, setHideResults ] = useState(false) const [ concedeCompleted, setConcedeCompleted ] = useState(false) - const gameData = store.useGameData() const [ activeId, setActiveId ] = useState(null) + const [ showDrawButton, setShowDrawButton ] = useState(false) + const gameData = store.useGameData() const playerHand = usePlayerHand() const dropAnimation: DropAnimation = { @@ -105,14 +108,33 @@ const Play: FablePage = ({ isHydrated }) => { const cancellationHandler = useCancellationHandler(loading) const cantDrawCard = cantTakeActions || gameData.currentStep !== GameStep.DRAW - const doDrawCard = useCallback( - () => drawCard({ - gameID: gameID!, - playerAddress: playerAddress!, - setLoading, - cancellationHandler - }), - [gameID, playerAddress, setLoading, cancellationHandler]) + const doDrawCard = useCallback(() => + drawCard({ + gameID: gameID!, + playerAddress: playerAddress!, + setLoading, + cancellationHandler, + }), + [gameID, playerAddress, setLoading, cancellationHandler]) + + useEffect(() => { + // Automatically submit the card draw transaction when it's our turn + if (gameData && currentPlayer(gameData) === playerAddress && !cantDrawCard) { + toast.promise(doDrawCard, { + id: "DRAW_CARD_TOAST", + loading: "Your Turn - Drawing Card...", + success: () => { + if (showDrawButton) setShowDrawButton(false) + return "Card Drawn Successfully!" + }, + error: () => { + if(!showDrawButton) setShowDrawButton(true) + return null as any // don't trigger the toast + }, + dismissible: true + }) + } + }, [cancellationHandler, cantDrawCard, gameID, playerAddress, doDrawCard, gameData, showDrawButton]) const cantEndTurn = cantTakeActions || !isEndingTurn(gameData.currentStep) const doEndTurn = useCallback( @@ -203,7 +225,7 @@ const Play: FablePage = ({ isHydrated }) => { cards={playerHand as readonly bigint[]} setLoading={setLoading} cancellationHandler={cancellationHandler} - className="absolute left-0 right-0 mx-auto z-[100] translate-y-1/2 transition-all duration-500 rounded-xl ease-in-out hover:translate-y-0" + className={`absolute left-0 right-0 mx-auto z-[100] translate-y-1/2 transition-all duration-500 rounded-xl ease-in-out hover:translate-y-0`} />
{ {!ended && ( <> - + {showDrawButton && + + }