diff --git a/components/brain/left-sidebar/waves/MemesWaveFooter.tsx b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx index e4aa89e664..1abee6e072 100644 --- a/components/brain/left-sidebar/waves/MemesWaveFooter.tsx +++ b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx @@ -1,10 +1,10 @@ "use client"; import MemesWaveQuickVoteTrigger from "@/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger"; +import MemesWaveZapIcon from "@/components/brain/left-sidebar/waves/MemesWaveZapIcon"; import { useMemesWaveFooterStats } from "@/hooks/useMemesWaveFooterStats"; import { formatNumberWithCommas } from "@/helpers/Helpers"; import { AnimatePresence, motion } from "framer-motion"; -import { BoltIcon } from "@heroicons/react/24/solid"; import React from "react"; interface MemesWaveFooterProps { @@ -53,7 +53,7 @@ const MemesWaveFooter: React.FC = ({ className={ collapsed ? "tw-z-10 tw-flex tw-flex-shrink-0 tw-justify-center tw-px-2 tw-pb-2 tw-pt-1" - : "tw-z-10 tw-flex-shrink-0 tw-bg-gradient-to-t tw-from-iron-950 tw-via-iron-950/95 tw-to-transparent tw-px-4 tw-pb-4 tw-pt-6" + : "tw-relative tw-z-20 tw-mt-auto tw-flex-shrink-0" } > {collapsed ? ( @@ -67,27 +67,37 @@ const MemesWaveFooter: React.FC = ({ type="button" aria-label={`Uncast Power, ${formatNumberWithCommas( uncastPower - )} ${votingLabel ?? "Votes"}, ${unratedCount} left`} + )} ${votingLabel ?? "Votes"} left, ${formatNumberWithCommas( + unratedCount + )} unexplored`} onClick={handleOpenQuickVote} onFocus={handlePrefetchQuickVote} onMouseEnter={handlePrefetchQuickVote} - className="tw-flex tw-w-full tw-items-center tw-justify-between tw-gap-x-4 tw-rounded-2xl tw-border tw-border-solid tw-border-primary-500/30 tw-bg-iron-900/95 tw-px-4 tw-py-3 tw-text-left tw-shadow-[0_18px_36px_rgba(0,0,0,0.28)] tw-backdrop-blur-sm tw-transition-colors tw-duration-300 desktop-hover:hover:tw-border-primary-400/40 desktop-hover:hover:tw-bg-iron-900" + className="tw-group tw-mt-auto tw-w-full tw-flex-shrink-0 tw-cursor-pointer tw-border-0 tw-border-t tw-border-solid tw-border-iron-800/60 tw-bg-black tw-p-4 tw-text-left tw-transition-colors desktop-hover:hover:tw-bg-iron-900/40" > -
-
- Uncast Power -
-
- - - {formatNumberWithCommas(uncastPower)} - {votingLabel ? ` ${votingLabel}` : ""} +
+
- - {formatNumberWithCommas(unratedCount)} left - )} diff --git a/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger.tsx b/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger.tsx index 158c9211ec..78c2663804 100644 --- a/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger.tsx +++ b/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger.tsx @@ -1,7 +1,7 @@ "use client"; +import MemesWaveZapIcon from "@/components/brain/left-sidebar/waves/MemesWaveZapIcon"; import { formatNumberWithCommas } from "@/helpers/Helpers"; -import { BoltIcon } from "@heroicons/react/24/solid"; import React from "react"; interface MemesWaveQuickVoteTriggerProps { @@ -33,7 +33,7 @@ const MemesWaveQuickVoteTrigger: React.FC = ({ className ?? "" }`} > - + {formatNumberWithCommas(unratedCount)} diff --git a/components/brain/left-sidebar/waves/MemesWaveZapIcon.tsx b/components/brain/left-sidebar/waves/MemesWaveZapIcon.tsx new file mode 100644 index 0000000000..89de7b78e8 --- /dev/null +++ b/components/brain/left-sidebar/waves/MemesWaveZapIcon.tsx @@ -0,0 +1,28 @@ +"use client"; + +import React from "react"; + +type MemesWaveZapIconProps = React.SVGProps; + +export default function MemesWaveZapIcon({ + className, + ...rest +}: MemesWaveZapIconProps) { + return ( + + + + ); +} diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteActionBar.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteActionBar.tsx new file mode 100644 index 0000000000..724b9f1ee1 --- /dev/null +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteActionBar.tsx @@ -0,0 +1,169 @@ +"use client"; + +import MemesWaveZapIcon from "@/components/brain/left-sidebar/waves/MemesWaveZapIcon"; +import { formatNumberWithCommas } from "@/helpers/Helpers"; +import useHasTouchInput from "@/hooks/useHasTouchInput"; +import clsx from "clsx"; +import { useEffect, useRef } from "react"; +import MemesQuickVoteCustomAmountRow from "./MemesQuickVoteCustomAmountRow"; +import MemesQuickVoteQuickAmountsRow from "./MemesQuickVoteQuickAmountsRow"; + +type VoteFeedbackSource = "custom-submit" | "quick-amount"; + +interface MemesQuickVoteActionBarProps { + readonly customValue: string; + readonly feedbackAmount: number | null; + readonly feedbackSource: VoteFeedbackSource | null; + readonly isCustomOpen: boolean; + readonly isSubmitting: boolean; + readonly isVoteFeedbackActive: boolean; + readonly latestUsedAmount: number | null; + readonly quickAmounts: readonly number[]; + readonly uncastPower: number | null; + readonly votingLabel: string | null; + readonly onCustomChange: (value: string) => void; + readonly onCustomSubmit: () => void; + readonly onOpenCustom: () => void; + readonly onSkip: () => void; + readonly onVoteAmount: (amount: number) => void; +} + +export default function MemesQuickVoteActionBar({ + customValue, + feedbackAmount, + feedbackSource, + isCustomOpen, + isSubmitting, + isVoteFeedbackActive, + latestUsedAmount, + quickAmounts, + uncastPower, + votingLabel, + onCustomChange, + onCustomSubmit, + onOpenCustom, + onSkip, + onVoteAmount, +}: MemesQuickVoteActionBarProps) { + const hasTouchInput = useHasTouchInput(); + const hasQuickAmounts = quickAmounts.length > 0; + const isCustomRowVisible = !hasQuickAmounts || isCustomOpen; + const customInputRef = useRef(null); + const previousCustomRowVisibleRef = useRef(isCustomRowVisible); + const customAmountLabel = + customValue.trim().length > 0 && Number.parseInt(customValue, 10) > 0 + ? formatNumberWithCommas(Number.parseInt(customValue, 10)) + : null; + + useEffect(() => { + const wasCustomRowVisible = previousCustomRowVisibleRef.current; + previousCustomRowVisibleRef.current = isCustomRowVisible; + + const justOpenedCustomRow = !wasCustomRowVisible && isCustomRowVisible; + + if ( + hasTouchInput || + !hasQuickAmounts || + !justOpenedCustomRow || + isSubmitting + ) { + return; + } + + customInputRef.current?.focus(); + }, [hasQuickAmounts, hasTouchInput, isCustomRowVisible, isSubmitting]); + + const handleToggleCustom = () => { + if (isSubmitting) { + return; + } + + onOpenCustom(); + }; + + return ( +
+
+
+
+

+ Quick Vote +

+ + {typeof uncastPower === "number" && ( +
+ + + + {formatNumberWithCommas(uncastPower)}{" "} + {votingLabel ?? "votes"} remaining + + +
+ )} +
+ +
+ {hasQuickAmounts && ( +
+ +
+ )} + +
+ +
+
+
+ + +
+
+ ); +} diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteAmountButton.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteAmountButton.tsx new file mode 100644 index 0000000000..d2df2a7129 --- /dev/null +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteAmountButton.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { formatNumberWithCommas } from "@/helpers/Helpers"; +import { CheckIcon } from "@heroicons/react/24/outline"; +import clsx from "clsx"; + +type VoteFeedbackSource = "custom-submit" | "quick-amount"; + +interface MemesQuickVoteAmountButtonProps { + readonly amount: number; + readonly feedbackAmount: number | null; + readonly feedbackSource: VoteFeedbackSource | null; + readonly isLatestUsed: boolean; + readonly isSubmitting: boolean; + readonly isVoteFeedbackActive: boolean; + readonly onVoteAmount: (amount: number) => void; +} + +export default function MemesQuickVoteAmountButton({ + amount, + feedbackAmount, + feedbackSource, + isLatestUsed, + isSubmitting, + isVoteFeedbackActive, + onVoteAmount, +}: MemesQuickVoteAmountButtonProps) { + const isQuickVoteFeedbackTarget = + isVoteFeedbackActive && + feedbackSource === "quick-amount" && + feedbackAmount === amount; + let buttonToneClassName = + "tw-border-white/5 tw-bg-white/[0.03] tw-text-[14px] tw-font-bold tw-text-zinc-300 tw-shadow-sm tw-transition-colors desktop-hover:hover:tw-bg-white/[0.06] desktop-hover:hover:tw-text-white"; + + if (isQuickVoteFeedbackTarget) { + buttonToneClassName = + "tw-border-emerald-500/35 tw-bg-emerald-500/15 tw-text-emerald-100 tw-shadow-[0_0_20px_rgba(16,185,129,0.15)]"; + } else if (isLatestUsed) { + buttonToneClassName = + "tw-border-blue-500/30 tw-bg-blue-500/15 tw-text-primary-300 tw-shadow-sm tw-transition-all desktop-hover:hover:tw-bg-blue-500/25 desktop-hover:hover:tw-text-primary-200"; + } + + return ( + + ); +} diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteControls.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteControls.tsx index 6e02451ff1..1e87c7bb6f 100644 --- a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteControls.tsx +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteControls.tsx @@ -1,34 +1,40 @@ "use client"; -import { AdjustmentsHorizontalIcon } from "@heroicons/react/24/outline"; -import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { formatNumberWithCommas } from "@/helpers/Helpers"; -import WaveDropAuthorPfp from "@/components/waves/drops/WaveDropAuthorPfp"; -import WaveDropTime from "@/components/waves/drops/time/WaveDropTime"; -import clsx from "clsx"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import MemesQuickVoteActionBar from "./MemesQuickVoteActionBar"; +import MemesQuickVoteDropHeader from "./MemesQuickVoteDropHeader"; + +type VoteFeedbackSource = "custom-submit" | "quick-amount"; interface MemesQuickVoteControlsProps { readonly customValue: string; readonly drop: ExtendedDrop; + readonly feedbackAmount: number | null; + readonly feedbackSource: VoteFeedbackSource | null; readonly isCustomOpen: boolean; readonly isSubmitting: boolean; + readonly isVoteFeedbackActive: boolean; readonly latestUsedAmount: number | null; readonly remainingCount: number; readonly quickAmounts: readonly number[]; readonly uncastPower: number | null; readonly votingLabel: string | null; readonly onCustomChange: (value: string) => void; - readonly onCustomSubmit: () => Promise; + readonly onCustomSubmit: () => void; readonly onOpenCustom: () => void; readonly onSkip: () => void; - readonly onVoteAmount: (amount: number) => Promise; + readonly onVoteAmount: (amount: number) => void; } export default function MemesQuickVoteControls({ customValue, drop, + feedbackAmount, + feedbackSource, isCustomOpen, isSubmitting, + isVoteFeedbackActive, latestUsedAmount, remainingCount, quickAmounts, @@ -40,198 +46,62 @@ export default function MemesQuickVoteControls({ onSkip, onVoteAmount, }: MemesQuickVoteControlsProps) { - const hasQuickAmounts = quickAmounts.length > 0; const title = drop.metadata.find((entry) => entry.data_key === "title")?.data_value ?? "Untitled submission"; const description = drop.metadata.find((entry) => entry.data_key === "description") ?.data_value ?? ""; - const authorLabel = drop.author.handle ?? drop.author.primary_address; - const customAmountLabel = - customValue.trim().length > 0 && Number.parseInt(customValue, 10) > 0 - ? formatNumberWithCommas(Number.parseInt(customValue, 10)) - : null; return ( -
-
-
- {typeof uncastPower === "number" && ( - - {formatNumberWithCommas(uncastPower)} {votingLabel ?? "votes"}{" "} - left - - )} - - {formatNumberWithCommas(remainingCount)} left - -
- -
-
- -
-
- - {authorLabel} - - - - - -
-

- {drop.wave.name} -

-
-
- -

- {title} -

- - {description && ( -

- {description} -

- )} -
+
+
+ + {formatNumberWithCommas(remainingCount)} unexplored +
-
-
-

- Quick Vote -

-

- Tap once to vote. Skip keeps it for later. -

-
- - {hasQuickAmounts && ( -
- {quickAmounts.map((amount) => { - const isLatestUsed = latestUsedAmount === amount; +
+
+
+ - return ( - - ); - })} +
+

+ {title} +

- -
- )} - - {(!hasQuickAmounts || isCustomOpen) && ( -
-
- - -
- )} - - +
+
+ +
); } diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteCustomAmountRow.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteCustomAmountRow.tsx new file mode 100644 index 0000000000..78c90a9f30 --- /dev/null +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteCustomAmountRow.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import clsx from "clsx"; +import type { RefObject } from "react"; + +type VoteFeedbackSource = "custom-submit" | "quick-amount"; + +const CUSTOM_AMOUNT_CONTROL_LABEL = "Change vote amount"; + +interface MemesQuickVoteCustomAmountRowProps { + readonly customAmountLabel: string | null; + readonly customInputRef: RefObject; + readonly customValue: string; + readonly feedbackSource: VoteFeedbackSource | null; + readonly hasQuickAmounts: boolean; + readonly isCustomRowVisible: boolean; + readonly isSubmitting: boolean; + readonly isVoteFeedbackActive: boolean; + readonly onCustomChange: (value: string) => void; + readonly onCustomSubmit: () => void; + readonly onToggleCustom: () => void; + readonly votingLabel: string | null; +} + +export default function MemesQuickVoteCustomAmountRow({ + customAmountLabel, + customInputRef, + customValue, + feedbackSource, + hasQuickAmounts, + isCustomRowVisible, + isSubmitting, + isVoteFeedbackActive, + onCustomChange, + onCustomSubmit, + onToggleCustom, + votingLabel, +}: MemesQuickVoteCustomAmountRowProps) { + return ( +
+ + + + + {hasQuickAmounts && ( + + )} +
+ ); +} diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog.tsx index 37d7a30866..512a8c4b24 100644 --- a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog.tsx +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog.tsx @@ -1,17 +1,32 @@ "use client"; -import { XMarkIcon } from "@heroicons/react/24/outline"; +import { formatNumberWithCommas } from "@/helpers/Helpers"; import { getDefaultQuickVoteAmount, normalizeQuickVoteAmount, } from "@/hooks/memesQuickVote.helpers"; +import { useMediaQuery } from "@/hooks/useMediaQuery"; import { useMemesQuickVoteQueue } from "@/hooks/useMemesQuickVoteQueue"; -import useIsMobileScreen from "@/hooks/isMobileScreen"; -import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { CheckCircleIcon } from "@heroicons/react/24/solid"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import MemesQuickVoteControls from "./MemesQuickVoteControls"; import MemesQuickVoteDialogSkeleton from "./MemesQuickVoteDialogSkeleton"; import MemesQuickVotePreview from "./MemesQuickVotePreview"; +const QUICK_VOTE_MOBILE_QUERY = "(max-width: 767px)"; +const QUICK_VOTE_BAR_FEEDBACK_DURATION_MS = 650; + +type VoteFeedbackSource = "custom-submit" | "quick-amount"; +type TimeoutHandle = ReturnType; + interface MemesQuickVoteDialogProps { readonly isOpen: boolean; readonly sessionId: number; @@ -24,6 +39,7 @@ interface MemesQuickVoteDialogContentProps { >; readonly isMobile: boolean; readonly latestUsedAmount: number | null; + readonly onClose: () => void; readonly remainingCount: number; readonly recentAmounts: number[]; readonly submitVote: ReturnType["submitVote"]; @@ -36,6 +52,7 @@ function MemesQuickVoteDialogContent({ activeDrop, isMobile, latestUsedAmount, + onClose, remainingCount, recentAmounts, submitVote, @@ -53,6 +70,13 @@ function MemesQuickVoteDialogContent({ () => recentAmounts.length === 0 ); const [isAdvancing, setIsAdvancing] = useState(false); + const [voteFeedback, setVoteFeedback] = useState<{ + readonly amount: number; + readonly source: VoteFeedbackSource; + } | null>(null); + const barVoteTimeoutRef = useRef(null); + const isVoteFeedbackActive = voteFeedback !== null; + const isControlsSubmitting = isAdvancing || isVoteFeedbackActive; const normalizedLatestUsedAmount = latestUsedAmount === null ? null @@ -79,29 +103,78 @@ function MemesQuickVoteDialogContent({ ? normalizedCustomAmount : (normalizedLatestUsedAmount ?? normalizedCustomAmount); - const queueVoteAmount = async (amount: number | string) => { - const wasQueued = await submitVote(activeDrop, amount); - - if (!wasQueued) { - setIsAdvancing(false); - } - }; - - const handleVoteAmount = async (amount: number | string) => { - if (isAdvancing) { + const clearBarVoteTimeout = useCallback(() => { + if (barVoteTimeoutRef.current === null) { return; } - setIsAdvancing(true); - await queueVoteAmount(amount); - }; + globalThis.clearTimeout(barVoteTimeoutRef.current); + barVoteTimeoutRef.current = null; + }, []); + + const queueVoteAmount = useCallback( + (amount: number | string) => { + submitVote(activeDrop, amount) + .then((wasQueued) => { + if (!wasQueued) { + setIsAdvancing(false); + setVoteFeedback(null); + } + }) + .catch(() => { + setIsAdvancing(false); + setVoteFeedback(null); + }); + }, + [activeDrop, submitVote] + ); + + useEffect( + () => () => { + clearBarVoteTimeout(); + }, + [clearBarVoteTimeout] + ); + + const handleBarVoteAmount = useCallback( + (amount: number | string, source: VoteFeedbackSource) => { + if (isAdvancing || isVoteFeedbackActive) { + return; + } + + const normalizedAmount = normalizeQuickVoteAmount(amount, maxRating); + + if (normalizedAmount === null) { + return; + } + + setIsAdvancing(true); + setVoteFeedback({ + amount: normalizedAmount, + source, + }); + clearBarVoteTimeout(); + barVoteTimeoutRef.current = globalThis.setTimeout(() => { + barVoteTimeoutRef.current = null; + setIsAdvancing(true); + queueVoteAmount(normalizedAmount); + }, QUICK_VOTE_BAR_FEEDBACK_DURATION_MS); + }, + [ + clearBarVoteTimeout, + isAdvancing, + isVoteFeedbackActive, + maxRating, + queueVoteAmount, + ] + ); const queueSkip = () => { skipDrop(activeDrop); }; const handleSkip = () => { - if (isAdvancing) { + if (isControlsSubmitting) { return; } @@ -110,72 +183,190 @@ function MemesQuickVoteDialogContent({ }; return ( -
- { - setIsAdvancing(true); - }} - onSkip={queueSkip} - onVoteWithSwipe={() => { - if (swipeVoteAmount === null) { - setIsAdvancing(false); - return; - } +
+ {isMobile && ( +
+ - void queueVoteAmount(swipeVoteAmount); - }} - /> +
+ + {formatNumberWithCommas(remainingCount)} unexplored + +
-
- { - if (value === "") { - setCustomValue(""); - return; - } - - setCustomValue(value.replace(/[^\d]/g, "")); - }} - onCustomSubmit={async () => { - await handleVoteAmount(customValue); - }} - onOpenCustom={() => { - setIsCustomOpen((current) => !current); - }} - onSkip={handleSkip} - onVoteAmount={handleVoteAmount} - /> -
+ + )} + + {isMobile ? ( +
+
+ { + setIsAdvancing(true); + }} + onSkip={queueSkip} + onVoteWithSwipe={() => { + if (swipeVoteAmount === null) { + setIsAdvancing(false); + return; + } + + queueVoteAmount(swipeVoteAmount); + }} + /> +
+ +
+ { + if (value === "") { + setCustomValue(""); + return; + } + + setCustomValue(value.replace(/[^\d]/g, "")); + }} + onCustomSubmit={() => { + handleBarVoteAmount(customValue, "custom-submit"); + }} + onOpenCustom={() => { + setIsCustomOpen((current) => !current); + }} + onSkip={handleSkip} + onVoteAmount={(amount) => { + handleBarVoteAmount(amount, "quick-amount"); + }} + /> +
+
+ ) : ( + <> +
+ { + setIsAdvancing(true); + }} + onSkip={queueSkip} + onVoteWithSwipe={() => { + if (swipeVoteAmount === null) { + setIsAdvancing(false); + return; + } + + queueVoteAmount(swipeVoteAmount); + }} + /> +
+ +
+ { + if (value === "") { + setCustomValue(""); + return; + } + + setCustomValue(value.replace(/[^\d]/g, "")); + }} + onCustomSubmit={() => { + handleBarVoteAmount(customValue, "custom-submit"); + }} + onOpenCustom={() => { + setIsCustomOpen((current) => !current); + }} + onSkip={handleSkip} + onVoteAmount={(amount) => { + handleBarVoteAmount(amount, "quick-amount"); + }} + /> +
+ + )}
); } -function MemesQuickVoteDialogDoneState() { +function MemesQuickVoteDialogDoneState({ + onClose, +}: { + readonly onClose: () => void; +}) { return ( -
-
-

- You are done +

+
+
+
+ +
+
+
+
+
+ +
+
+

+ You're all caught up

-

+

No unrated memes are left in quick vote right now.

+ +
); @@ -187,8 +378,8 @@ function MemesQuickVoteDialogErrorState({ readonly onRetry: () => void; }) { return ( -
-
+
+

Couldn't load your queue

@@ -215,7 +406,7 @@ export default function MemesQuickVoteDialog({ const dialogRef = useRef(null); const previouslyFocusedElementRef = useRef(null); const previousBodyOverflowRef = useRef(""); - const isMobile = useIsMobileScreen(); + const isMobile = useMediaQuery(QUICK_VOTE_MOBILE_QUERY); const { activeDrop, hasDiscoveryError, @@ -289,7 +480,7 @@ export default function MemesQuickVoteDialog({ let dialogBody: ReactNode; if (isExhausted) { - dialogBody = ; + dialogBody = ; } else if (!activeDrop && hasDiscoveryError) { dialogBody = ; } else if (!activeDrop && isLoading) { @@ -299,10 +490,11 @@ export default function MemesQuickVoteDialog({ } else { dialogBody = (
{ if (event.target !== event.currentTarget) { return; @@ -330,18 +522,18 @@ export default function MemesQuickVoteDialog({ onClose(); }} > -
+
-
+
{dialogBody}
diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialogSkeleton.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialogSkeleton.tsx index f39a91b192..fd23037866 100644 --- a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialogSkeleton.tsx +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialogSkeleton.tsx @@ -1,14 +1,70 @@ "use client"; -const QUICK_AMOUNT_KEYS = [ - "quick-vote-amount-1", - "quick-vote-amount-2", -] as const; - function SkeletonBlock({ className }: { readonly className: string }) { return
; } +function MemesQuickVoteDropHeaderSkeleton() { + return ( +
+ +
+
+ + + +
+ +
+
+ ); +} + +function MemesQuickVoteCopySkeleton({ + titleClassName, +}: { + readonly titleClassName: string; +}) { + return ( +
+ +
+ + + + +
+
+ ); +} + +function MemesQuickVoteActionBarSkeleton() { + return ( +
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ + +
+
+ ); +} + export default function MemesQuickVoteDialogSkeleton() { return (

Loading your queue. Pulling unrated memes and your recent quick-vote amounts.

-
-
- - - -
+
+ +
-
-
-
-
- -
-
- - - -
- -
-
-
+
+ + +
+
+
+
+
-
- -
- - - -
+
+
-
-
- +
+
+
+ + +
-
- -
-
-
-
- - -
-
-
- -
-
- - - -
- -
-
- - -
- - - -
-
-
- -
-
- - -
- -
+
+ +
-
- {QUICK_AMOUNT_KEYS.map((key) => ( - - ))} +
+
+
-
-
-
- - +
+
+
+ +
-
+
- +
diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDropHeader.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDropHeader.tsx new file mode 100644 index 0000000000..9eec7460a2 --- /dev/null +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDropHeader.tsx @@ -0,0 +1,35 @@ +"use client"; + +import WaveDropAuthorPfp from "@/components/waves/drops/WaveDropAuthorPfp"; +import WaveDropTime from "@/components/waves/drops/time/WaveDropTime"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; + +interface MemesQuickVoteDropHeaderProps { + readonly drop: ExtendedDrop; +} + +export default function MemesQuickVoteDropHeader({ + drop, +}: MemesQuickVoteDropHeaderProps) { + const authorLabel = drop.author.handle ?? drop.author.primary_address; + + return ( +
+ +
+
+ + {authorLabel} + + + • + + +
+

+ {drop.wave.name} +

+
+
+ ); +} diff --git a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVotePreview.tsx b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVotePreview.tsx index f6dd01a287..36f319e39e 100644 --- a/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVotePreview.tsx +++ b/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVotePreview.tsx @@ -1,17 +1,15 @@ "use client"; import DropListItemContentMedia from "@/components/drops/view/item/content/media/DropListItemContentMedia"; -import WaveDropAuthorPfp from "@/components/waves/drops/WaveDropAuthorPfp"; -import WaveDropTime from "@/components/waves/drops/time/WaveDropTime"; import { formatNumberWithCommas } from "@/helpers/Helpers"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import clsx from "clsx"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo } from "react"; +import { Swiper, SwiperSlide } from "swiper/react"; +import MemesQuickVoteDropHeader from "./MemesQuickVoteDropHeader"; +import useMemesQuickVotePreviewSwipe from "./useMemesQuickVotePreviewSwipe"; -const SWIPE_TRIGGER_THRESHOLD = 96; -const MAX_SWIPE_OFFSET = 132; -const SWIPE_EXIT_DURATION_MS = 180; -const SWIPE_EXIT_OFFSET = 420; +const MOBILE_SWIPE_CENTER_SLIDE_INDEX = 1; interface MemesQuickVotePreviewProps { readonly drop: ExtendedDrop; @@ -38,14 +36,6 @@ function MemesQuickVotePreviewContent({ onSkip, onVoteWithSwipe, }: MemesQuickVotePreviewProps) { - const [swipeOffset, setSwipeOffset] = useState(0); - const [swipeExitDirection, setSwipeExitDirection] = useState< - "left" | "right" | null - >(null); - const swipeCommitTimeoutRef = useRef(null); - const touchStartXRef = useRef(null); - const touchStartYRef = useRef(null); - const title = drop.metadata.find((entry) => entry.data_key === "title")?.data_value ?? "Untitled submission"; @@ -53,7 +43,6 @@ function MemesQuickVotePreviewContent({ drop.metadata.find((entry) => entry.data_key === "description") ?.data_value ?? ""; const artworkMedia = drop.parts.at(0)?.media.at(0); - const authorLabel = drop.author.handle ?? drop.author.primary_address; const swipeHint = useMemo(() => { if (swipeVoteAmount === null) { return null; @@ -63,140 +52,59 @@ function MemesQuickVotePreviewContent({ votingLabel ?? "votes" }`; }, [swipeVoteAmount, votingLabel]); - - const resetSwipe = () => { - if (swipeExitDirection) { - return; - } - - setSwipeOffset(0); - touchStartXRef.current = null; - touchStartYRef.current = null; - }; - - const clearSwipeCommitTimeout = () => { - if (swipeCommitTimeoutRef.current !== null) { - window.clearTimeout(swipeCommitTimeoutRef.current); - swipeCommitTimeoutRef.current = null; - } - }; - - useEffect(() => { - return () => { - if (swipeCommitTimeoutRef.current !== null) { - window.clearTimeout(swipeCommitTimeoutRef.current); - swipeCommitTimeoutRef.current = null; - } - }; - }, []); - - const beginSwipeCommit = ( - direction: "left" | "right", - action: () => void - ) => { - clearSwipeCommitTimeout(); - setSwipeExitDirection(direction); - setSwipeOffset( - direction === "left" ? -SWIPE_EXIT_OFFSET : SWIPE_EXIT_OFFSET - ); - touchStartXRef.current = null; - touchStartYRef.current = null; - onAdvanceStart(); - swipeCommitTimeoutRef.current = window.setTimeout(() => { - action(); - }, SWIPE_EXIT_DURATION_MS); - }; - - const handleTouchStart = (event: React.TouchEvent) => { - if (isBusy || !isMobile || swipeExitDirection || !event.touches[0]) { - return; - } - - touchStartXRef.current = event.touches[0].clientX; - touchStartYRef.current = event.touches[0].clientY; - }; - - const handleTouchMove = (event: React.TouchEvent) => { - if ( - isBusy || - !isMobile || - swipeExitDirection || - touchStartXRef.current === null || - touchStartYRef.current === null || - !event.touches[0] - ) { - return; - } - - const deltaX = event.touches[0].clientX - touchStartXRef.current; - const deltaY = event.touches[0].clientY - touchStartYRef.current; - - if (Math.abs(deltaY) > Math.abs(deltaX)) { - return; - } - - setSwipeOffset( - Math.max(-MAX_SWIPE_OFFSET, Math.min(deltaX, MAX_SWIPE_OFFSET)) - ); - }; - - const handleTouchEnd = () => { - if (isBusy || !isMobile) { - resetSwipe(); - return; - } - - if (swipeOffset <= -SWIPE_TRIGGER_THRESHOLD) { - beginSwipeCommit("left", onSkip); - return; - } - - if (swipeOffset >= SWIPE_TRIGGER_THRESHOLD && swipeVoteAmount !== null) { - beginSwipeCommit("right", onVoteWithSwipe); - return; - } - - resetSwipe(); - }; - - let cardTransform: React.CSSProperties["transform"]; - if (isMobile) { - if (swipeExitDirection === "left") { - cardTransform = `translateX(-${SWIPE_EXIT_OFFSET}px) rotate(-6deg)`; - } else if (swipeExitDirection === "right") { - cardTransform = `translateX(${SWIPE_EXIT_OFFSET}px) rotate(6deg)`; - } else { - cardTransform = `translateX(${swipeOffset}px)`; - } - } - - return ( -
+ const swipeInstructionText = swipeHint + ? `Swipe left to skip, right to vote ${swipeHint}` + : null; + const { + previewCardRef, + canUseSwiperTouchSurface, + cardTransform, + cardTransitionDuration, + handleCardTransitionEnd, + handleSwiperMove, + handleSwiperTouchEnd, + swipeOffset, + } = useMemesQuickVotePreviewSwipe({ + isBusy, + isMobile, + onAdvanceStart, + onSkip, + onVoteWithSwipe, + swipeVoteAmount, + }); + + const previewCard = ( +
- {typeof uncastPower === "number" && ( - - {formatNumberWithCommas(uncastPower)} {votingLabel ?? "votes"} left - - )} - - {remainingCount} left - - {isMobile && ( - - Swipe left to skip{swipeHint ? `, right to vote ${swipeHint}` : ""} - - )} -
- -
- {isMobile && ( - <> + {artworkMedia ? ( +
+
+ +
@@ -204,92 +112,88 @@ function MemesQuickVotePreviewContent({
0 ? "tw-opacity-100" : "tw-opacity-0" )} > {swipeHint ? `Vote ${swipeHint}` : "Vote"}
- +
+ ) : ( +
+ Preview unavailable +
)} -
-
-
- -
-
- - {authorLabel} - - - - - -
-

- {drop.wave.name} -

+
+
+
+ + +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )}
+
+
+
+ ); -
-
-

- {title} -

- {description && ( -

- {description} -

- )} -
+ return ( +
+
+ {swipeInstructionText && ( + {swipeInstructionText} + )} + {typeof uncastPower === "number" && ( + + {formatNumberWithCommas(uncastPower)} {votingLabel ?? "votes"} left + + )} + +
- {artworkMedia ? ( -
-
- -
-
- ) : ( -
- Preview unavailable -
- )} -
-
+
+ {canUseSwiperTouchSurface ? ( + +