From 1cb9ffa249e6bae2a3bfa7595420d962584d7650 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Fri, 8 Aug 2025 10:46:00 +0400 Subject: [PATCH] refactor: switch to ripple button --- package.json | 1 + pnpm-lock.yaml | 60 +++++++ src/components/app-update-toast.tsx | 9 +- src/components/camoufox-config-dialog.tsx | 16 +- src/components/change-version-dialog.tsx | 5 +- src/components/create-group-dialog.tsx | 11 +- src/components/create-profile-dialog.tsx | 5 +- src/components/delete-confirmation-dialog.tsx | 22 ++- src/components/delete-group-dialog.tsx | 9 +- src/components/edit-group-dialog.tsx | 9 +- src/components/group-assignment-dialog.tsx | 9 +- src/components/group-management-dialog.tsx | 11 +- src/components/home-header.tsx | 10 +- src/components/import-profile-dialog.tsx | 19 +-- src/components/loading-button.tsx | 11 +- src/components/permission-dialog.tsx | 5 +- src/components/profile-data-table.tsx | 13 +- src/components/profile-selector-dialog.tsx | 9 +- src/components/proxy-form-dialog.tsx | 5 +- src/components/proxy-management-dialog.tsx | 11 +- src/components/proxy-settings-dialog.tsx | 15 +- src/components/release-type-selector.tsx | 5 +- src/components/settings-dialog.tsx | 5 +- src/components/ui/combobox.tsx | 5 +- src/components/ui/ripple.tsx | 146 ++++++++++++++++++ 25 files changed, 344 insertions(+), 82 deletions(-) create mode 100644 src/components/ui/ripple.tsx diff --git a/package.json b/package.json index ebda7fb..6038b25 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "motion": "^12.23.12", "next": "^15.4.6", "next-themes": "^0.4.6", "react": "^19.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ab6db3..ab4eef5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + motion: + specifier: ^12.23.12 + version: 12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next: specifier: ^15.4.6 version: 15.4.6(@babel/core@7.28.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1860,6 +1863,20 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + framer-motion@12.23.12: + resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -2343,6 +2360,26 @@ packages: resolution: {integrity: sha512-DXO4L9W+08T+A7h5+xdT32l7IMot8z7WOH+7C1Maol571PnktQ8un7Ni4CyPFp4H+vht/FDA5/tpjRvWMFQDMw==} engines: {node: '>=10', npm: '>=6'} + motion-dom@12.23.12: + resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + + motion@12.23.12: + resolution: {integrity: sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4520,6 +4557,15 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + framer-motion@12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + motion-dom: 12.23.12 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + fs-constants@1.0.0: {} fs-minipass@2.1.0: @@ -4981,6 +5027,20 @@ snapshots: mmdb-lib@2.2.1: {} + motion-dom@12.23.12: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + + motion@12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + framer-motion: 12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + tslib: 2.8.1 + optionalDependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + ms@2.1.3: {} nano-spawn@1.0.2: {} diff --git a/src/components/app-update-toast.tsx b/src/components/app-update-toast.tsx index 1606a07..7030c04 100644 --- a/src/components/app-update-toast.tsx +++ b/src/components/app-update-toast.tsx @@ -5,6 +5,7 @@ import { LuCheckCheck, LuCog, LuRefreshCw } from "react-icons/lu"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import type { AppUpdateInfo, AppUpdateProgress } from "@/types"; +import { RippleButton } from "./ui/ripple"; interface AppUpdateToastProps { updateInfo: AppUpdateInfo; @@ -160,22 +161,22 @@ export function AppUpdateToast({ {!isUpdating && (
- - +
)} diff --git a/src/components/camoufox-config-dialog.tsx b/src/components/camoufox-config-dialog.tsx index bdff7ef..0aa2083 100644 --- a/src/components/camoufox-config-dialog.tsx +++ b/src/components/camoufox-config-dialog.tsx @@ -12,6 +12,8 @@ import { } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import type { BrowserProfile, CamoufoxConfig } from "@/types"; +import { LoadingButton } from "./loading-button"; +import { RippleButton } from "./ui/ripple"; interface CamoufoxConfigDialogProps { isOpen: boolean; @@ -117,12 +119,16 @@ export function CamoufoxConfigDialog({ - - + + + Save + diff --git a/src/components/change-version-dialog.tsx b/src/components/change-version-dialog.tsx index a219b24..7d6a964 100644 --- a/src/components/change-version-dialog.tsx +++ b/src/components/change-version-dialog.tsx @@ -19,6 +19,7 @@ import { Label } from "@/components/ui/label"; import { useBrowserDownload } from "@/hooks/use-browser-download"; import { getBrowserDisplayName } from "@/lib/browser-utils"; import type { BrowserProfile, BrowserReleaseTypes } from "@/types"; +import { RippleButton } from "./ui/ripple"; interface ChangeVersionDialogProps { isOpen: boolean; @@ -288,9 +289,9 @@ export function ChangeVersionDialog({ - + { diff --git a/src/components/create-group-dialog.tsx b/src/components/create-group-dialog.tsx index 5175a2a..44cbe0f 100644 --- a/src/components/create-group-dialog.tsx +++ b/src/components/create-group-dialog.tsx @@ -16,6 +16,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import type { ProfileGroup } from "@/types"; +import { RippleButton } from "./ui/ripple"; interface CreateGroupDialogProps { isOpen: boolean; @@ -98,15 +99,19 @@ export function CreateGroupDialog({ - + void handleCreate()} disabled={!groupName.trim()} > - Create Group + Create diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index ac55bf6..2e235a0 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -27,6 +27,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useBrowserDownload } from "@/hooks/use-browser-download"; import { getBrowserIcon } from "@/lib/browser-utils"; import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types"; +import { RippleButton } from "./ui/ripple"; type BrowserTypeString = | "mullvad-browser" @@ -580,9 +581,9 @@ export function CreateProfileDialog({ - + - - + Cancel + + void handleConfirm()} + isLoading={isLoading} + > + {confirmButtonText} + diff --git a/src/components/delete-group-dialog.tsx b/src/components/delete-group-dialog.tsx index d1c1a25..d11e567 100644 --- a/src/components/delete-group-dialog.tsx +++ b/src/components/delete-group-dialog.tsx @@ -17,6 +17,7 @@ import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { ScrollArea } from "@/components/ui/scroll-area"; import type { BrowserProfile, ProfileGroup } from "@/types"; +import { RippleButton } from "./ui/ripple"; interface DeleteGroupDialogProps { isOpen: boolean; @@ -188,9 +189,13 @@ export function DeleteGroupDialog({ - + - + void handleUpdate()} diff --git a/src/components/group-assignment-dialog.tsx b/src/components/group-assignment-dialog.tsx index f4ec345..02916c5 100644 --- a/src/components/group-assignment-dialog.tsx +++ b/src/components/group-assignment-dialog.tsx @@ -22,6 +22,7 @@ import { SelectValue, } from "@/components/ui/select"; import type { ProfileGroup } from "@/types"; +import { RippleButton } from "./ui/ripple"; interface GroupAssignmentDialogProps { isOpen: boolean; @@ -161,9 +162,13 @@ export function GroupAssignmentDialog({ - + void handleAssign()} diff --git a/src/components/group-management-dialog.tsx b/src/components/group-management-dialog.tsx index ee584d1..80dd8e1 100644 --- a/src/components/group-management-dialog.tsx +++ b/src/components/group-management-dialog.tsx @@ -26,6 +26,7 @@ import { TableRow, } from "@/components/ui/table"; import type { ProfileGroup } from "@/types"; +import { RippleButton } from "./ui/ripple"; interface GroupManagementDialogProps { isOpen: boolean; @@ -119,14 +120,14 @@ export function GroupManagementDialog({ {/* Create new group button */}
- + Create +
{error && ( @@ -187,9 +188,9 @@ export function GroupManagementDialog({ - + diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx index f6e6a51..31fa22a 100644 --- a/src/components/home-header.tsx +++ b/src/components/home-header.tsx @@ -11,6 +11,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "./ui/dropdown-menu"; +import { RippleButton } from "./ui/ripple"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; type Props = { @@ -46,7 +47,6 @@ const HomeHeader = ({
- +
) : ( diff --git a/src/components/import-profile-dialog.tsx b/src/components/import-profile-dialog.tsx index a053bf3..076e1c4 100644 --- a/src/components/import-profile-dialog.tsx +++ b/src/components/import-profile-dialog.tsx @@ -26,6 +26,7 @@ import { import { useBrowserSupport } from "@/hooks/use-browser-support"; import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils"; import type { DetectedProfile } from "@/types"; +import { RippleButton } from "./ui/ripple"; interface ImportProfileDialogProps { isOpen: boolean; @@ -242,7 +243,7 @@ export function ImportProfileDialog({ ); if (profile) { const browserName = getBrowserDisplayName(profile.browser); - const defaultName = `Imported ${browserName} Profile`; + const defaultName = `Old ${browserName}`; setAutoDetectProfileName(defaultName); } } @@ -268,7 +269,7 @@ export function ImportProfileDialog({
{/* Mode Selection */}
- - +
{/* Auto-Detect Mode */} @@ -479,9 +480,9 @@ export function ImportProfileDialog({
- + {importMode === "auto-detect" ? ( - Import Profile + Import ) : ( - Import Profile + Import )} diff --git a/src/components/loading-button.tsx b/src/components/loading-button.tsx index 78f169b..4a563ae 100644 --- a/src/components/loading-button.tsx +++ b/src/components/loading-button.tsx @@ -1,5 +1,8 @@ import { LuLoaderCircle } from "react-icons/lu"; -import { type ButtonProps, Button as UIButton } from "./ui/button"; +import { + type RippleButtonProps as ButtonProps, + RippleButton as UIButton, +} from "./ui/ripple"; type Props = ButtonProps & { isLoading: boolean; @@ -7,7 +10,11 @@ type Props = ButtonProps & { }; export const LoadingButton = ({ isLoading, ...props }: Props) => { return ( - + {isLoading ? ( ) : ( diff --git a/src/components/permission-dialog.tsx b/src/components/permission-dialog.tsx index 23e825e..99857f0 100644 --- a/src/components/permission-dialog.tsx +++ b/src/components/permission-dialog.tsx @@ -15,6 +15,7 @@ import { import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { RippleButton } from "./ui/ripple"; interface PermissionDialogProps { isOpen: boolean; @@ -148,9 +149,9 @@ export function PermissionDialog({ - + {!isCurrentPermissionGranted && ( - - + + void handleRename()}> + Save + diff --git a/src/components/profile-selector-dialog.tsx b/src/components/profile-selector-dialog.tsx index b8d8431..43ba664 100644 --- a/src/components/profile-selector-dialog.tsx +++ b/src/components/profile-selector-dialog.tsx @@ -30,6 +30,7 @@ import { import { useBrowserState } from "@/hooks/use-browser-state"; import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils"; import type { BrowserProfile, StoredProxy } from "@/types"; +import { RippleButton } from "./ui/ripple"; interface ProfileSelectorDialogProps { isOpen: boolean; @@ -196,7 +197,7 @@ export function ProfileSelectorDialog({
- +
{url} @@ -312,9 +313,9 @@ export function ProfileSelectorDialog({
- + diff --git a/src/components/proxy-form-dialog.tsx b/src/components/proxy-form-dialog.tsx index fbb5e33..f43c021 100644 --- a/src/components/proxy-form-dialog.tsx +++ b/src/components/proxy-form-dialog.tsx @@ -22,6 +22,7 @@ import { SelectValue, } from "@/components/ui/select"; import type { StoredProxy } from "@/types"; +import { RippleButton } from "./ui/ripple"; interface ProxyFormData { name: string; @@ -264,13 +265,13 @@ export function ProxyFormDialog({
- + - + {/* Proxy List - Scrollable */} @@ -150,10 +151,10 @@ export function ProxyManagementDialog({

Create your first proxy configuration to get started

- + ) : (
@@ -221,7 +222,7 @@ export function ProxyManagementDialog({
- + Close diff --git a/src/components/proxy-settings-dialog.tsx b/src/components/proxy-settings-dialog.tsx index 98e4849..36b99ba 100644 --- a/src/components/proxy-settings-dialog.tsx +++ b/src/components/proxy-settings-dialog.tsx @@ -23,6 +23,7 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { StoredProxy } from "@/types"; +import { RippleButton } from "./ui/ripple"; interface ProxySettingsDialogProps { isOpen: boolean; @@ -142,15 +143,15 @@ export function ProxySettingsDialog({ - + Create +

Create a new proxy configuration

@@ -263,12 +264,12 @@ export function ProxySettingsDialog({ - - + diff --git a/src/components/release-type-selector.tsx b/src/components/release-type-selector.tsx index 159fa59..7bbabf1 100644 --- a/src/components/release-type-selector.tsx +++ b/src/components/release-type-selector.tsx @@ -19,6 +19,7 @@ import { } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import type { BrowserReleaseTypes } from "@/types"; +import { RippleButton } from "./ui/ripple"; interface ReleaseTypeSelectorProps { selectedReleaseType: "stable" | "nightly" | null; @@ -85,7 +86,7 @@ export function ReleaseTypeSelector({ {showDropdown ? ( - + diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 0b25e46..536c812 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -33,6 +33,7 @@ import { showSuccessToast, showUnifiedVersionUpdateToast, } from "@/lib/toast-utils"; +import { RippleButton } from "./ui/ripple"; interface AppSettings { set_as_default_browser: boolean; @@ -529,9 +530,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { - + { diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx index 14e57a1..18bf624 100644 --- a/src/components/ui/combobox.tsx +++ b/src/components/ui/combobox.tsx @@ -18,6 +18,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; +import { RippleButton } from "./ripple"; interface ComboboxOption { value: string; @@ -47,7 +48,7 @@ export function Combobox({ return ( - + diff --git a/src/components/ui/ripple.tsx b/src/components/ui/ripple.tsx new file mode 100644 index 0000000..85d9c3c --- /dev/null +++ b/src/components/ui/ripple.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { cva, type VariantProps } from "class-variance-authority"; +import { type HTMLMotionProps, motion, type Transition } from "motion/react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "relative overflow-hidden cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + }, + size: { + default: "h-10 px-4 py-2 has-[>svg]:px-3", + sm: "h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-11 px-8 has-[>svg]:px-6", + icon: "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +const rippleVariants = cva("absolute rounded-full size-5 pointer-events-none", { + variants: { + variant: { + default: "bg-primary-foreground", + destructive: "bg-destructive", + outline: "bg-input", + secondary: "bg-secondary", + ghost: "bg-accent", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +type Ripple = { + id: number; + x: number; + y: number; +}; + +type RippleButtonProps = HTMLMotionProps<"button"> & { + children: React.ReactNode; + rippleClassName?: string; + scale?: number; + transition?: Transition; +} & VariantProps; + +function RippleButton({ + ref, + children, + onClick, + className, + rippleClassName, + variant, + size, + scale = 10, + transition = { duration: 0.6, ease: "easeOut" }, + ...props +}: RippleButtonProps) { + const [ripples, setRipples] = React.useState([]); + const buttonRef = React.useRef(null); + React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement); + + const createRipple = React.useCallback( + (event: React.MouseEvent) => { + const button = buttonRef.current; + if (!button) return; + + const rect = button.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + const newRipple: Ripple = { + id: Date.now(), + x, + y, + }; + + setRipples((prev) => [...prev, newRipple]); + + setTimeout(() => { + setRipples((prev) => prev.filter((r) => r.id !== newRipple.id)); + }, 600); + }, + [], + ); + + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + createRipple(event); + if (onClick) { + onClick(event); + } + }, + [createRipple, onClick], + ); + + return ( + + {children} + {ripples.map((ripple) => ( + + ))} + + ); +} + +export { RippleButton, type RippleButtonProps };