diff --git a/public/locales/en/transactionFlow.json b/public/locales/en/transactionFlow.json index d61128554..425e81d08 100644 --- a/public/locales/en/transactionFlow.json +++ b/public/locales/en/transactionFlow.json @@ -3,9 +3,12 @@ "profileEditor": { "tabs": { "avatar": { + "change": "Change avatar", + "label": "Avatar", "dropdown": { "selectNFT": "Select NFT", - "uploadImage": "Upload Image" + "uploadImage": "Upload Image", + "enterManually": "Enter manually" }, "nft": { "title": "Select an NFT", diff --git a/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx b/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx index 31b270ab0..380a640c8 100644 --- a/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx +++ b/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx @@ -2,17 +2,17 @@ import { ComponentProps, Dispatch, SetStateAction, useRef } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' -import { Avatar, Dropdown } from '@ensdomains/thorin' +import { Avatar, Button, Dropdown } from '@ensdomains/thorin' import { DropdownItem } from '@ensdomains/thorin/dist/types/components/molecules/Dropdown/Dropdown' import CameraIcon from '@app/assets/Camera.svg' import { LegacyDropdown } from '@app/components/@molecules/LegacyDropdown/LegacyDropdown' -const Container = styled.button<{ $error?: boolean; $validated?: boolean; $dirty?: boolean }>( +const AvatarWrapper = styled.button<{ $error?: boolean; $validated?: boolean; $dirty?: boolean }>( ({ theme, $validated, $dirty, $error }) => css` position: relative; - width: 90px; - height: 90px; + width: 120px; + height: 120px; border-radius: 50%; background-color: ${theme.colors.backgroundPrimary}; cursor: pointer; @@ -66,8 +66,8 @@ const IconMask = styled.div( position: absolute; top: 0; left: 0; - width: 90px; - height: 90px; + width: 120px; + height: 120px; border-radius: 50%; display: flex; align-items: center; @@ -83,7 +83,25 @@ const IconMask = styled.div( `, ) -export type AvatarClickType = 'upload' | 'nft' +const ActionContainer = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.space[2]}; + max-width: 200px; + `, +) + +const Container = styled.div( + ({ theme }) => css` + width: 100%; + display: flex; + align-items: center; + gap: ${theme.space[4]}; + `, +) + +export type AvatarClickType = 'upload' | 'nft' | 'manual' type PickedDropdownProps = Pick, 'isOpen' | 'setIsOpen'> @@ -100,8 +118,8 @@ type Props = { const AvatarButton = ({ validated, - dirty, error, + dirty, src, onSelectOption, onAvatarChange, @@ -129,41 +147,56 @@ const AvatarButton = ({ : ({} as { isOpen: never; setIsOpen: never }) return ( - - + + {!validated && !error && ( )} + + + + + + - - + + ) } diff --git a/src/components/@molecules/ProfileEditor/Avatar/AvatarManual.tsx b/src/components/@molecules/ProfileEditor/Avatar/AvatarManual.tsx new file mode 100644 index 000000000..d6d156f11 --- /dev/null +++ b/src/components/@molecules/ProfileEditor/Avatar/AvatarManual.tsx @@ -0,0 +1,94 @@ +/* eslint-disable no-multi-assign */ + +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { Button, Dialog, Helper, Input } from '@ensdomains/thorin' + +import { useUploadAvatar } from './useUploadAvatar' + +type AvatarManualProps = { + name: string + handleCancel: () => void + handleSubmit: (type: 'manual', uri: string, display?: string) => void +} + +function isValidHttpUrl(value: string) { + let url + + try { + url = new URL(value) + } catch (_) { + return false + } + + return url.protocol === 'http:' || url.protocol === 'https:' +} + +export function AvatarManual({ name, handleCancel, handleSubmit }: AvatarManualProps) { + const { t } = useTranslation('transactionFlow') + + const [value, setValue] = useState('') + + const { signAndUpload, isPending, error } = useUploadAvatar() + + const handleUpload = async () => { + try { + const dataURL = await fetch(value) + .then((res) => res.blob()) + .then((blob) => { + return new Promise((res) => { + const reader = new FileReader() + + reader.onload = (e) => { + if (e.target) res(e.target.result as string) + } + + reader.readAsDataURL(blob) + }) + }) + + const endpoint = await signAndUpload({ dataURL, name }) + + if (endpoint) { + handleSubmit('manual', endpoint, value) + } + } catch (e) { + console.error(e) + } + } + + return ( + <> + + + setValue(e.target.value)} + /> + + {error && ( + + {error.message} + + )} + handleCancel()}> + {t('action.back', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/components/@molecules/ProfileEditor/Avatar/AvatarUpload.tsx b/src/components/@molecules/ProfileEditor/Avatar/AvatarUpload.tsx index 6959e53e9..36c33a47d 100644 --- a/src/components/@molecules/ProfileEditor/Avatar/AvatarUpload.tsx +++ b/src/components/@molecules/ProfileEditor/Avatar/AvatarUpload.tsx @@ -1,17 +1,12 @@ /* eslint-disable no-multi-assign */ -import { sha256 } from '@noble/hashes/sha256' -import { useMutation, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' -import { bytesToHex } from 'viem' -import { useAccount, useSignTypedData } from 'wagmi' import { Button, Dialog, Helper } from '@ensdomains/thorin' -import { useChainName } from '@app/hooks/chain/useChainName' - import { AvCancelButton, CropComponent } from './AvatarCrop' +import { useUploadAvatar } from './useUploadAvatar' const CroppedImagePreview = styled.img( ({ theme }) => css` @@ -22,21 +17,6 @@ const CroppedImagePreview = styled.img( `, ) -const dataURLToBytes = (dataURL: string) => { - const base64 = dataURL.split(',')[1] - const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) - return bytes -} - -type AvatarUploadResult = - | { - message: string - } - | { - error: string - status: number - } - const UploadComponent = ({ dataURL, handleCancel, @@ -49,82 +29,16 @@ const UploadComponent = ({ name: string }) => { const { t } = useTranslation('transactionFlow') - const queryClient = useQueryClient() - const chainName = useChainName() - const { address } = useAccount() - const { signTypedDataAsync } = useSignTypedData() + const { signAndUpload, isPending, error } = useUploadAvatar() - const { - mutate: signAndUpload, - isPending, - error, - } = useMutation({ - mutationFn: async () => { - let baseURL = process.env.NEXT_PUBLIC_AVUP_ENDPOINT || `https://euc.li` - if (chainName !== 'mainnet') { - baseURL = `${baseURL}/${chainName}` - } - const endpoint = `${baseURL}/${name}` + const handleUpload = async () => { + const endpoint = await signAndUpload({ dataURL, name }) - const urlHash = bytesToHex(sha256(dataURLToBytes(dataURL))) - const expiry = `${Date.now() + 1000 * 60 * 60 * 24 * 7}` - - const sig = await signTypedDataAsync({ - primaryType: 'Upload', - domain: { - name: 'Ethereum Name Service', - version: '1', - }, - types: { - Upload: [ - { name: 'upload', type: 'string' }, - { name: 'expiry', type: 'string' }, - { name: 'name', type: 'string' }, - { name: 'hash', type: 'string' }, - ], - }, - message: { - upload: 'avatar', - expiry, - name, - hash: urlHash, - }, - }) - const fetched = (await fetch(endpoint, { - method: 'PUT', - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - expiry, - dataURL, - sig, - unverifiedAddress: address, - }), - }).then((res) => res.json())) as AvatarUploadResult - - if ('message' in fetched && fetched.message === 'uploaded') { - queryClient.invalidateQueries({ - predicate: (query) => { - const { - queryKey: [params], - } = query - if (params !== 'ensAvatar') return false - return true - }, - }) - return handleSubmit('upload', endpoint, dataURL) - } - - if ('error' in fetched) { - throw new Error(fetched.error) - } - - throw new Error('Unknown error') - }, - }) + if (endpoint) { + handleSubmit('upload', endpoint, dataURL) + } + } return ( <> @@ -146,7 +60,7 @@ const UploadComponent = ({