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..6842587d9 100644 --- a/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx +++ b/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx @@ -2,17 +2,12 @@ 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; border-radius: 50%; background-color: ${theme.colors.backgroundPrimary}; cursor: pointer; @@ -61,29 +56,26 @@ const Container = styled.button<{ $error?: boolean; $validated?: boolean; $dirty `, ) -const IconMask = styled.div( +const ActionContainer = styled.div( ({ theme }) => css` - position: absolute; - top: 0; - left: 0; - width: 90px; - height: 90px; - border-radius: 50%; display: flex; - align-items: center; - justify-content: center; - background: linear-gradient(0deg, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)); - border: 4px solid ${theme.colors.grey}; + flex-direction: column; + align-items: flex-start; + gap: ${theme.space[2]}; overflow: hidden; + `, +) - svg { - width: 40px; - display: block; - } +const Container = styled.div( + ({ theme }) => css` + display: grid; + grid-template-columns: 120px 1fr; + align-items: center; + gap: ${theme.space[4]}; `, ) -export type AvatarClickType = 'upload' | 'nft' +export type AvatarClickType = 'upload' | 'nft' | 'manual' type PickedDropdownProps = Pick, 'isOpen' | 'setIsOpen'> @@ -100,8 +92,8 @@ type Props = { const AvatarButton = ({ validated, - dirty, error, + dirty, src, onSelectOption, onAvatarChange, @@ -129,55 +121,70 @@ const AvatarButton = ({ : ({} as { isOpen: never; setIsOpen: never }) return ( - - + + - {!validated && !error && ( - - - - )} - { - if (e.target.files?.[0]) { - onSelectOption?.('upload') - onAvatarFileChange?.(e.target.files[0]) - } - }} - /> - - + + + + + {!!src && ( + + )} +
+ +
+ { + if (e.target.files?.[0]) { + onSelectOption?.('upload') + onAvatarFileChange?.(e.target.files[0]) + } + }} + /> +
+
+ ) } 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..de1e36a95 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,20 @@ const UploadComponent = ({ name: string }) => { const { t } = useTranslation('transactionFlow') - const queryClient = useQueryClient() - const chainName = useChainName() - - const { address } = useAccount() - const { signTypedDataAsync } = useSignTypedData() - 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 urlHash = bytesToHex(sha256(dataURLToBytes(dataURL))) - const expiry = `${Date.now() + 1000 * 60 * 60 * 24 * 7}` + const { signAndUpload, isPending, error } = useUploadAvatar() - 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) - } + const handleUpload = async () => { + try { + const endpoint = await signAndUpload({ dataURL, name }) - if ('error' in fetched) { - throw new Error(fetched.error) + if (endpoint) { + handleSubmit('upload', endpoint, dataURL) } - - throw new Error('Unknown error') - }, - }) + } catch (e) { + console.error(e) + } + } return ( <> @@ -146,7 +64,7 @@ const UploadComponent = ({