Web3 приложение Twitter на React.js + Solidity | часть 2
- четверг, 14 марта 2024 г. в 00:00:21
Hello, в первой части был подготовлен проект, подключены кошельки и написан backend на Solidity, значит пришло время писать frontend на React.
Проект далёк от продакшена и является простым примером для новичков, предназначенным для демонстрации взаимодействия с смарт-контрактом через веб-приложение.
Возвращаемся в главную папку проекта web3, в которой создаем проект на реакте.
$ npx create-react-app client
Далее нужно поставить две библиотеки: react-router-dom
иweb3
. Первая нужна для перехода между страницами, а вторая для работы со смарт-контрактом.
cd client
npm i react-router-dom, web3
Если используете yarn
:
cd client
yarn add web3
yarn add react-router-dom
Видим наши библиотеки в зависимостях, значит можем начинать.
Начнем с удаления лишних файлов.
Переходим в файл index.js и импортируем BrowserRouter
из библиотеки react-router-dom
, также оборачиваем компонент App
в роутер. Удаляем лишние комментарии в коде и чистим импорты.
Далее создаем две папки: config и contexts. В папке config создаем два файла: abi.json (в нем будет abi нашего смарт-контракта) и contract.js (в нем будет адрес нашего смарт-контракта).
P.S: адрес контракта можно посмотреть в 1 блоке в Ganache
.
ABI - в контексте смарт-контрактов - это как бы язык общения между различными программами или компонентами программного обеспечения.
В случае с смарт-контрактами, ABI представляет собой набор правил и форматов данных, которые определяют, как внешние программы могут взаимодействовать с этими контрактами. Это включает в себя формат передачи данных, типы данных и структуру функций.
В папке contexts создаем файл ContractContext.js в нем мы опишем подключение к кошельку MetaMask
и смарт-контракту, а также возвращаемые переменные, чтобы иметь к ним доступ на любой странице сайта, просто вызвав useContract
.
В файле contract.js будет всего одна строчка:
export const contract_address = "адрес вашего смарт-контракта";
Теперь нужно найти ABI нашего контракта, чтобы вставить его в файл abi.json. Для этого переходим в папку contracts с нашим бэкэндом и открываем папку build/contarcts и в ней файл Twitter.json. В нем находим abi и полностью копируем.
Просто вставляем скопированный текст в наш файл abi.json. Ниже abi для контракта из первой части.
[
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [
{
"internalType": "string",
"name": "_username",
"type": "string"
},
{
"internalType": "string",
"name": "_password",
"type": "string"
}
],
"name": "Registration",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "string",
"name": "_password",
"type": "string"
}
],
"name": "Login",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "Logout",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "string",
"name": "_text",
"type": "string"
}
],
"name": "AddTwitt",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_user",
"type": "address"
}
],
"name": "UserTwitts",
"outputs": [
{
"components": [
{
"components": [
{
"internalType": "address",
"name": "login",
"type": "address"
},
{
"internalType": "string",
"name": "password",
"type": "string"
},
{
"internalType": "string",
"name": "username",
"type": "string"
},
{
"internalType": "string",
"name": "avatar",
"type": "string"
}
],
"internalType": "struct Twitter.User",
"name": "author",
"type": "tuple"
},
{
"internalType": "string",
"name": "text",
"type": "string"
},
{
"internalType": "uint256",
"name": "likes",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "createdTime",
"type": "uint256"
}
],
"internalType": "struct Twitter.Twitt[]",
"name": "",
"type": "tuple[]"
}
],
"stateMutability": "view",
"type": "function",
"constant": true
},
{
"inputs": [
{
"internalType": "address",
"name": "_user",
"type": "address"
}
],
"name": "CheckRegistration",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function",
"constant": true
},
{
"inputs": [
{
"internalType": "address",
"name": "_user",
"type": "address"
}
],
"name": "GetUser",
"outputs": [
{
"components": [
{
"internalType": "address",
"name": "login",
"type": "address"
},
{
"internalType": "string",
"name": "password",
"type": "string"
},
{
"internalType": "string",
"name": "username",
"type": "string"
},
{
"internalType": "string",
"name": "avatar",
"type": "string"
}
],
"internalType": "struct Twitter.User",
"name": "",
"type": "tuple"
}
],
"stateMutability": "view",
"type": "function",
"constant": true
},
{
"inputs": [
{
"internalType": "string",
"name": "_avatar",
"type": "string"
}
],
"name": "UpdateUser",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
]
Далее пишем ContractProvider
, надеюсь вы хотя бы немного знакомы с js и react, но в любом случае код будет простым.
В первую очередь подтягиваем web3 библиотеку и наш адрес контракта вместе с abi.
Далее создаем контекст ContractContext
.
Контекст используется для передачи данных вниз по дереву компонентов без явной передачи пропсов.
В самом компоненте ContractProvider
объявляем несколько состояний.
contract
- содержит экземпляр смарт-контракта.
account
- содержит адрес пользователя вошедшего в аккаунт.
accounts
- содержит адреса всех пользователей.
balance
- содержит баланс пользователя вошедшего в аккаунт.
const [contract, setContract] = useState(null);
const [account, setAccount] = useState(null);
const [accounts, setAccounts] = useState([]);
const [balance, setBalance] = useState();
Переходим к написанию функцию подключения аккаунта пользователя к приложению. В ней мы создаем экземпляр объекта web3
для взаимодействия с Ethereum. Подключение идет через MetaMask
или локальный сервер с адресом и портом, которые мы указывали в Ganache
.
Если расширение MetaMask
установлено, то через window.ethereum.request
получаем все аккаунты и первый ([0]) записываем, как адрес пользователя.
С помощью web3.eth.getBalance(accounts[0])
получаем баланс аккаунта пользователя и также записываем в состояние баланса (перед этим переводим из wei в ether) и берем только первые 7 символов.
Ниже создаем экземпляр контракта и также записываем в состояние.
const contractInstance = new web3.eth.Contract(abi, contract_address);
setContract(contractInstance);
Оборачиваем все в try-catch для надежности и вызываем функцию внутри UseEffect
, тем самым функция будет выполняться при монтировании компонента.
useEffect(() => {
const initializeContract = async () => {
const web3 = new Web3(Web3.givenProvider || 'http://127.0.0.1:7545');
if (window.ethereum) {
try {
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
if (accounts && accounts.length > 0) {
setAccounts(accounts);
setAccount(accounts[0]);
const weiBalance = await web3.eth.getBalance(accounts[0]);
const etherBalance = web3.utils.fromWei(weiBalance, 'ether');
setBalance(etherBalance.slice(0, 8));
const contractInstance = new web3.eth.Contract(abi, contract_address);
setContract(contractInstance);
} else {
console.error('No accounts found');
}
} catch (error) {
console.error('Error fetching account balance:', error);
}
} else {
alert('Install MetaMask extension!');
}
};
initializeContract();
}, []);
Возвращать будем дочерние компоненты обернутые в ContractContext.Provider
и наши стейты с аккаунтами, балансом и т.д.
return (
<ContractContext.Provider value={{ contract, account, balance, accounts }}>
{children}
</ContractContext.Provider>
);
// ContractContext.js
import React, { createContext, useContext, useState, useEffect } from 'react';
import Web3 from 'web3';
import { contract_address } from '../config/contract';
import abi from '../config/abi.json';
const ContractContext = createContext();
export const ContractProvider = ({ children }) => {
const [contract, setContract] = useState(null);
const [account, setAccount] = useState(null);
const [accounts, setAccounts] = useState([]);
const [balance, setBalance] = useState();
useEffect(() => {
const initializeContract = async () => {
const web3 = new Web3(Web3.givenProvider || 'http://127.0.0.1:7545');
if (window.ethereum) {
try {
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
if (accounts && accounts.length > 0) {
setAccounts(accounts);
setAccount(accounts[0]);
const weiBalance = await web3.eth.getBalance(accounts[0]);
const etherBalance = web3.utils.fromWei(weiBalance, 'ether');
setBalance(etherBalance.slice(0, 8));
const contractInstance = new web3.eth.Contract(abi, contract_address);
setContract(contractInstance);
} else {
console.error('No accounts found');
}
} catch (error) {
console.error('Error fetching account balance:', error);
}
} else {
alert('Install MetaMask extension!');
}
};
initializeContract();
}, []);
return (
<ContractContext.Provider value={{ contract, account, balance, accounts }}>
{children}
</ContractContext.Provider>
);
};
export const useContract = () => {
const context = useContext(ContractContext);
if (!context) {
throw new Error('useContract must be used within a ContractProvider');
}
return context;
};
Не забываем обернуть router
и app
в наш ContractProvider
.
Далее по большому счету будет обычная верстка и создание компонентов, код всех css файлов будет в конце, не вижу смысла акцентировать внимание на дизайне.
В первую очередь создадим свой header
, т.к. функционал сильно ограничен, давим на него название приложения и иконку профиля для перехода на другую страницу.
Создаем папку components для наших компонентов и папку с константами, в нее мы добавляем главный цвет приложения.
В CustomHeader
просто добавляет заголовок для названия и картинку для кнопки перехода на страницу профиля. Для перехода используем Link
из react-router-dom
.
export default function CustomHeader() {
return (
<div style={{
height: '10vh',
backgroundColor: theme.primaryColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingInline: '10%'
}}
>
<Link to={"/"}><h1 style={{color: 'white'}}>Twitter dApp</h1></Link>
<Link to={"/profile"}>
<img style={{
width: '6vh',
height: '6vh'}}
src={process.env.PUBLIC_URL + '/userIcon.png'}
alt='user'/>
</Link>
</div>
)
}
Также создадим папку pages и в ней 4 страницы: главная, профиль, профиль другого пользователя и страницу "404". Сейчас в них только div
с названием страницы
Переходим в файл App.js и импортируем наш CustomHeader
. Внутри функции добавляем наш CustomHeader
и routes
. В качестве элементов страниц передаем наши страницы из папки pages.
У страницы "404" в качестве пути указываем "*". Теперь если пользователь поменяет путь на несуществующий (любой кроме тех, что мы объявили), ему выдаст страницу с ошибкой.
У страницы другого пользователя добавляем к пути динамический параметр ":userAdr" чтобы в параметрах запроса передавать адрес пользователя и получать его на странице.
function App() {
return (
<div className="App">
<CustomHeader/>
<Routes>
<Route path="/" element={<Home/>} />
<Route path="/profile" element={<Profile/>} />
<Route path='/user/:userAdr' element={<User/>} />
<Route path='*' element={<ErrorPage/>} />
</Routes>
</div>
);
}
На странице с ошибкой добавляем базовую надпись и кнопку возвращающую на главную страницу ("/").
return (
<div className='App-header'>
<h1 style={{textAlign: 'center'}}>Oops...<br/>Page not found</h1>
<Link to="/" style={{
backgroundColor: '#3366FF',
padding: '10px',
paddingInline: '20px',
borderRadius: '5px',
color: 'white'}}>Go home</Link>
</div>
)
Запускаем Ganache
и клиент:
cd client
npm run start
После этого видим наш header и открытое окно MetaMask
с подключением аккаунта к приложению. Выбираем первый аккаунт и подключаем.
Также если изменим путь страницы на несуществующий, то увидим страницу с ошибкой. Все работает и осталось добавить компонент твитта и контент для профиля.
В папке components создаем файл Twitt.jsx, в качестве аргумента, он будет принимать сам твитт, который будет передаваться на странице вывода твиттов.
Из нашего контекста импортируем только адрес аккаунта, он пригодится для проверки на автора. Объявляем константу isOwner
, ее значение зависит от того, является ли авторизованный пользователь автором твитта. Также переводим дату в понятный для пользователя формат.
const { account } = useContract();
const isOwner = account == twit.author.login.toLowerCase();
const date = new Date(Number(twit.createdTime) * 1000);
const formattedDateTime = date.toLocaleString();
Далее просто выводим в div
блоки поля из переданного твитта и заворачиваем аватарку автора и никнейм в Link
, чтобы при нажатии делать переход на страницу пользователя.
Если авторизованный пользователь является автором твитта, то переход будет не на страницу автора, а в профиль. При переходе на страницу автора передаем в параметрах его адрес, который хранится в
twitt
.P.S: Правильно будет сделать одну страницу профиля и разграничить логику и дизайн для владельца и гостя. Но в рамках данного приложения мы разделили страницу на две.
import React from 'react'
import theme from '../constants/colors'
import './Twitt.css';
import { Link } from 'react-router-dom';
import { useContract } from '../contexts/ContractContext';
export default function Twitt({twit}) {
const { account } = useContract();
const isOwner = account == twit.author.login.toLowerCase();
const date = new Date(Number(twit.createdTime) * 1000);
const formattedDateTime = date.toLocaleString();
return (
<div className='Twitt'>
<Link style={{color: 'black'}} to={isOwner ? '/profile' : `/user/${twit.author.login}`}>
<div style={{display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '2vh'}}>
<img className='twitt-pic' src={twit.author.avatar ? twit.author.avatar : `${process.env.PUBLIC_URL}/profile-pic.png`} alt="Profile Picture"/>
<div>
<h3>@{twit.author.username}</h3>
<h3 style={{color: theme.primaryColor}}>{formattedDateTime}</h3>
</div>
</div>
</Link>
<h3 style={{marginTop: '2vh'}}>{twit.text}</h3>
<div className="likes">
<img src={process.env.PUBLIC_URL+'/like.png'} className='like'/>
<h3>{twit.likes.toString()}</h3>
</div>
</div>
)
}
Переходим к странице профиля, в ней сразу объявляем несколько стейтов. Для удобства я не стал выносить формы входа и обновление аватарки в отдельные компоненты или модальные окна, поэтому все будет хранить внутри одной страницы.
Здесь мы импортируем из контракта баланс и адрес пользователя, чтобы отобразить его в профиле, а также сам контракт для работы с ним.
Также объявляем состояние для текстовых полей регистрации, входа и текста твитта и несколько boolean состояний. isOpened
нужен для отслеживания нажатия кнопки включения изменения аватарки пользователя (при значении true
будет отображаться поле для ввода ссылки на картинку).
const { contract, balance, account } = useContract();
const [user, setUser] = useState();
const [link, setLink] = useState();
const [text, setText] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isOpened, setIsOpened] = useState(false);
const [isLogged, setIsLogged] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);
const [twitts, setTwitts] = useState([]);
Далее объявляем useEffect
, который будет обновляться при изменении одного из трех стейтов (контракт, регистрация или вход). Если контракт инициализирован, то получает состояние авторизации из localStorage
, также вызываем функцию проверяющую зарегистрирован пользователь или нет. Если пользователь авторизован, то получаем его данные и твитты. Сами функции будут описаны ниже.
useEffect(() => {
if (contract) {
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
setIsLogged(isLoggedIn);
CheckRegistration();
if(isLogged) {
GetUserData();
getUserTwitts();
}
}
}, [isLogged, contract, isRegistered]);
Создадим универсальную функцию для работы с контрактом, в ней мы будем получать в параметрах название функции в смарт-контракте и необходимые для передачи аргументы.
Также с помощью estimateGas
узнаем стоимость транзакции и передаем ее вторым аргументом. В Ganache
также можно указать 0-ую цену транзакций и не указывать газ при вызове функций. Все функции будут вызываться с адреса пользователя, поэтому указываем from: account
.
const sendTransaction = async (method, args) => {
try {
const gasEstimation = await method(...args).estimateGas({ from: account });
await method(...args).send({ from: account, gas: gasEstimation });
} catch (err) {
alert(err);
}
};
Теперь создаем функции регистрации, авторизации и написания твитта в которых проверяем, что переданные аргументы не пустые и передаем их в функцию sendTransaction
, в качестве method
, передаем название функции в самом смарт-контракте.
const AddTwitt = async(_text) => {
if(!_text) return;
try {
await sendTransaction(contract.methods.AddTwitt, [_text]);
setText('');
getUserTwitts();
} catch (err) {
alert(err);
}
}
const Registration = async(_username, _password) => {
if(!_username || !_password) return;
try {
await sendTransaction(contract.methods.Registration, [_username, _password]);
setIsRegistered(true);
} catch (err) {
alert(err);
}
}
const Login = async(_password) => {
if(!_password) return;
try {
await sendTransaction(contract.methods.Login, [_password]);
localStorage.setItem('isLoggedIn', true);
setIsLogged(true);
} catch (err) {
alert(err);
}
}
В функция с получение данных, мы обращаемся к функция контракта через call
, и не платим за их вызов, поэтому не передаем стоимость газа. В качестве аргумента передаем адрес пользователя, т.к. получаем его данные.
const getUserTwitts = async() => {
try {
const twitts = await contract.methods.UserTwitts(account).call({ from: account });
setTwitts(twitts);
} catch (err) {
alert(err);
}
}
const GetUserData = async() => {
try {
const user = await contract.methods.GetUser(account).call({ from: account });
setUser(user);
} catch (err) {
alert(err);
}
}
Далее обычная верстка страницы, рассмотрим основные моменты.
Во первых добавляем условие, что если пользователь не вошел в аккаунт и не зарегистрирован, то выводим ему поля для регистрации, и при успешной регистрации или если пользователь уже имеет аккаунт, у него будет только поле для ввода пароля (ведь логином является адрес аккаунта).
Если пользователь уже в аккаунте, то отображаем кнопку выхода.
<div>
{
isLogged
? (<button className='edit-btn' onClick={() => Exit()}>exit</button>)
: (
isRegistered
? (
<div className='registration'>
<input type='text' placeholder='password' value={password} onChange={e => setPassword(e.target.value)}/>
<button className='edit-btn' style={{margin: '0'}} onClick={() => Login(password)}>login</button>
</div>
)
: (
<div className='registration'>
<input type='text' placeholder='username' value={username} onChange={e => setUsername(e.target.value)}/>
<input type='text' placeholder='password' value={password} onChange={e => setPassword(e.target.value)}/>
<button className='edit-btn' style={{margin: '0'}} onClick={() => Registration(username, password)}>registration</button>
</div>
)
)
}
</div>
Также в боковом меню отображаем кнопку обновления аватарки, при нажатии на которую будет появляться текстовое поле для ввода ссылки и новая кнопка.
<div className='sidebar'>
{user && <h2 style={{marginBottom: '4vh'}}>
<span style={{color: 'gray'}}>Username:</span><br/>@{user.username}
</h2>}
{isOpened && <input type='text' style={{marginBottom: '1vh'}} placeholder='link to img' value={link} onChange={e => setLink(e.target.value)}/>}
{isOpened
? <button className='edit-btn' onClick={() => UpdateAvatar(link)}>save</button>
: <button className='edit-btn' onClick={() => setIsOpened(true)}>update avatar</button>
}
</div>
Для вывода всех твиттов через map
проходим по массиву твиттов и передаем каждый твитт в наш компонент Twitt
, в качестве ключа передаем дату создания твитта, также сортируем их по времени, чтобы сначала отображались новые.
Лучше
{
twitts.sort((a, b) => Number(b.createdTime) - Number(a.createdTime)).map((twit) => {
return (
<Twitt key={twit.createdTime} twit={twit} />
);
})
}
Полный код страницы профиля
import React from 'react'
import '../App.css';
import { useEffect, useState } from 'react';
import './Profile.css';
import theme from '../constants/colors';
import { useContract } from '../contexts/ContractContext';
import Twitt from '../components/Twitt';
export default function Profile() {
const { contract, balance, account } = useContract();
const [user, setUser] = useState();
const [link, setLink] = useState();
const [text, setText] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isOpened, setIsOpened] = useState(false);
const [isLogged, setIsLogged] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);
const [twitts, setTwitts] = useState([]);
useEffect(() => {
if (contract) {
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
setIsLogged(isLoggedIn);
CheckRegistration();
if(isLogged) {
GetUserData();
getUserTwitts();
}
}
}, [isLogged, contract, isRegistered]);
const sendTransaction = async (method, args) => {
try {
const gasEstimation = await method(...args).estimateGas({ from: account });
await method(...args).send({ from: account, gas: gasEstimation });
} catch (err) {
alert(err);
}
};
const AddTwitt = async(_text) => {
if(!_text) return;
try {
await sendTransaction(contract.methods.AddTwitt, [_text]);
setText('');
getUserTwitts();
} catch (err) {
alert(err);
}
}
const Registration = async(_username, _password) => {
if(!_username || !_password) return;
try {
await sendTransaction(contract.methods.Registration, [_username, _password]);
setIsRegistered(true);
} catch (err) {
alert(err);
}
}
const Login = async(_password) => {
if(!_password) return;
try {
await sendTransaction(contract.methods.Login, [_password]);
localStorage.setItem('isLoggedIn', true);
setIsLogged(true);
} catch (err) {
alert(err);
}
}
const getUserTwitts = async() => {
try {
const twitts = await contract.methods.UserTwitts(account).call({ from: account });
setTwitts(twitts);
} catch (err) {
alert(err);
}
}
const GetUserData = async() => {
try {
const user = await contract.methods.GetUser(account).call({ from: account });
setUser(user);
} catch (err) {
alert(err);
}
}
const UpdateAvatar = async(link) => {
if(!link) return;
try {
await sendTransaction(contract.methods.UpdateUser, [link]);
setLink('');
setIsOpened(false);
GetUserData();
} catch (err) {
alert(err);
}
}
const Exit = async() => {
try {
await sendTransaction(contract.methods.Logout, []);
localStorage.setItem('isLoggedIn', false);
setIsLogged(false);
setTwitts([]);
} catch (err) {
alert(err);
}
}
const CheckRegistration = async() => {
try {
const result = await contract.methods.CheckRegistration(account).call({ from: account });
setIsRegistered(result);
} catch (err) {
alert(err);
}
}
return (
<div className='main'>
<div className='container'
style={{
alignItems: 'start',
justifyContent: 'flex-start',
gap: '5vh'}}>
<div>
<img src={user && user.avatar ? user.avatar : process.env.PUBLIC_URL + '/basicProfile.png'} alt="avatar" className='avatar' />
</div>
<div className='user-info'>
<div style={{marginTop: '5vh'}}>
<h2>Account: <span style={{color: theme.primaryColor}}>{account}</span></h2>
<h2>Balance: {balance} BEBRA</h2>
</div>
{
isLogged
? (<button className='edit-btn' onClick={() => Exit()}>exit</button>)
: (
isRegistered
? (
<div className='registration'>
<input type='text' placeholder='password' value={password} onChange={e => setPassword(e.target.value)}/>
<button className='edit-btn' style={{margin: '0'}} onClick={() => Login(password)}>login</button>
</div>
)
: (
<div className='registration'>
<input type='text' placeholder='username' value={username} onChange={e => setUsername(e.target.value)}/>
<input type='text' placeholder='password' value={password} onChange={e => setPassword(e.target.value)}/>
<button className='edit-btn' style={{margin: '0'}} onClick={() => Registration(username, password)}>registration</button>
</div>
)
)
}
</div>
</div>
<div className="container" style={{
alignItems: 'start',
justifyContent: 'flex-start',
marginBottom: '5vh',
gap: '5vh'
}}>
{isLogged && <div className='sidebar'>
{user && <h2 style={{marginBottom: '4vh'}}>
<span style={{color: 'gray'}}>Username:</span><br/>@{user.username}
</h2>}
{isOpened && <input type='text' style={{marginBottom: '1vh'}} placeholder='link to img' value={link} onChange={e => setLink(e.target.value)}/>}
{isOpened
? <button className='edit-btn' onClick={() => UpdateAvatar(link)}>save</button>
: <button className='edit-btn' onClick={() => setIsOpened(true)}>update avatar</button>
}
</div>}
<div style={{display: 'flex', flexDirection: 'column', gap: '5vh', width: '80%'}}>
{isLogged && <div className='add-twitt'>
<textarea className='input_twitt' type='text' placeholder='What are you thinking?' value={text} onChange={e => setText(e.target.value)}/>
<button onClick={() => AddTwitt(text)} disabled={!isLogged} className='add-btn'>Twitt it</button>
</div>}
{
twitts.sort((a, b) => Number(b.createdTime) - Number(a.createdTime)).map((twit) => {
return (
<Twitt key={twit.createdTime} twit={twit} />
);
})
}
</div>
</div>
</div>
)
}
Переходим на страницу профиля и видим поля для регистрации. Создадим аккаунт и попробуем войти в него.
После входа в аккаунт мы видим все наши блоки, попробуем обновить фото вставим ссылку на картинку из браузера, а также напишем твитт.
После успешного написания твитта сразу видим его в ленте, также видим нашу новую картинку (также можно поставить гифку).
Теперь дело за малым, осталось сделать главную страницу со всеми твиттами других пользователей. Для этого также создаем стейт для твиттов и импортируем из контекста все аккаунты, вместе с аккаунтом самого пользователя.
Создаем функцию для получения всех твиттов. В ней мы проходим по всем аккаунтам и получаем твитты пользователя, чей адрес находится на текущей итерации. Все твитты заносим в сет, чтобы хранить только уникальные твитты и после завершения цикла заносим твитты в стейт.
Не лучший пример получения данных, и по большому счету стоит написать функцию на сервере, которая вернет все твитты. Но в рамках данного проекта, для простоты освоения оставим такое решение.
const getAllTwitts = async() => {
try {
const uniqueTwittsSet = new Set();
for (const acc of accounts) {
if(acc != account) {
const _twitts = await contract.methods.UserTwitts(acc).call({ from: account });
_twitts.forEach((twit) => uniqueTwittsSet.add(twit));
}
};
const uniqueTwittsArray = [...uniqueTwittsSet];
setTwitts(uniqueTwittsArray);
} catch (err) {
alert(err);
}
}
Далее также через map
, выводим твитты.
import React, {useEffect, useState} from 'react'
import { useContract } from '../contexts/ContractContext';
import Twitt from '../components/Twitt';
export default function Home() {
const { contract, accounts, account } = useContract();
const [twitts, setTwitts] = useState([]);
const getAllTwitts = async() => {
try {
const uniqueTwittsSet = new Set();
for (const acc of accounts) {
if(acc != account) {
const _twitts = await contract.methods.UserTwitts(acc).call({ from: account });
_twitts.forEach((twit) => uniqueTwittsSet.add(twit));
}
};
const uniqueTwittsArray = [...uniqueTwittsSet];
setTwitts(uniqueTwittsArray);
} catch (err) {
alert(err);
}
}
useEffect(() => {
if (contract) {
getAllTwitts();
}
}, [contract]);
return (
<div className='main'>
<div className="container" style={{display: 'flex', justifyContent: 'center', marginBottom: '5vh'}}>
<div style={{display: 'flex', flexDirection: 'column', gap: '5vh', width: '80%'}}>
{
twitts.sort((a, b) => Number(b.createdTime) - Number(a.createdTime)).map((twit) => {
return (
<Twitt key={twit.createdTime} twit={twit} />
);
})
}
</div>
</div>
</div>
)
}
Выйдем из аккаунта в профиле и переключим аккаунт в MetaMask
, также пройдем этап регистрации для нового аккаунта и напишем твитт.
И теперь на главной странице мы видим твитт первого пользователя, т.к. зашли за аккаунт второго.
Если мы нажмем на никнейм или картинку пользователя, то перейдем на его страницу и видим в ссылке на страницу его адрес, осталось скопировать верстку страницы профиля и убрать все лишнее.
Из всех функций и элементов страницы оставляем только получение данных пользователя и его твитты. Здесь уже в качестве аргумента передаем адрес пользователя полученного из параметров страницы (его мы указывали в маршрутизации как userAdr
).
P.S: Повторюсь, что не стоит так делать и правильно будет сделать одну страницу для авторизованного пользователя и других юзеров.
import React, { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom';
import '../App.css';
import './Profile.css';
import theme from '../constants/colors';
import { useContract } from '../contexts/ContractContext';
import Twitt from '../components/Twitt';
export default function User() {
const { contract, account } = useContract();
const { userAdr } = useParams();
const [user, setUser] = useState();
const [twitts, setTwitts] = useState([]);
useEffect(() => {
if (contract) {
GetUserData();
getUserTwitts();
}
}, [contract]);
const getUserTwitts = async() => {
try {
const twitts = await contract.methods.UserTwitts(userAdr).call({ from: account });
setTwitts(twitts);
} catch (err) {
alert(err);
}
}
const GetUserData = async() => {
try {
const _user = await contract.methods.GetUser(userAdr).call({ from: account });
setUser(_user);
} catch (err) {
alert(err);
}
}
return (
<div className='main'>
<div className='container'
style={{
alignItems: 'start',
justifyContent: 'flex-start',
gap: '5vh'}}>
<div>
<img src={user && user.avatar ? user.avatar : process.env.PUBLIC_URL + '/basicProfile.png'} alt="avatar" className='avatar' />
</div>
<div className='user-info'>
<div style={{marginTop: '5vh'}}>
<h2>Account: <span style={{color: theme.primaryColor}}>{userAdr}</span></h2>
</div>
</div>
</div>
<div className="container" style={{
alignItems: 'start',
justifyContent: 'flex-start',
marginBottom: '5vh',
gap: '5vh'
}}>
<div className='sidebar'>
{user && <h2 style={{marginBottom: '4vh'}}>
<span style={{color: 'gray'}}>Username:</span><br/>@{user.username}
</h2>}
</div>
<div style={{display: 'flex', flexDirection: 'column', gap: '5vh', width: '80%'}}>
{
twitts.sort((a, b) => Number(b.createdTime) - Number(a.createdTime)).map((twit) => {
return (
<Twitt key={twit.createdTime} twit={twit} />
);
})
}
</div>
</div>
</div>
)
}
На этом клиентская часть готова. Ниже будет код для всех css файлов.
Twitt.css
.Twitt {
width: calc(100%-2vh);
height: auto;
padding: 2vh;
padding-top: 0.5vh;
border-color: var(--primary-color);
border-style: groove;
border-width: 2px;
border-radius: 2vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: start;
}
h3, h2 {
text-align: left;
margin: 0;
padding: 0;
}
.likes {
margin-top: 4vh;
display: flex;
flex-direction: row;
gap: 1vh;
align-items: center;
}
.like {
width: 4vh;
height: 4vh;
}
.twitt-pic {
width: 6vh;
height: 6vh;
}
Profile.css
.avatar {
height: 30vh;
width: 30vh;
border-radius: 3vh;
}
.user-info {
height: 30vh;
display: flex;
text-align: left;
flex-direction: column;
justify-content: space-between;
}
.edit-btn {
cursor: pointer;
margin-bottom: 5vh;
min-width: 20vh;
width: auto;
max-width: 30vh;
height: 5vh;
color: white;
font-size: 1.2rem;
background-color: var(--primary-color);
border-radius: 2vh;
border-style: none;
}
.registration {
display: flex;
align-items: center;
gap: 1.5vh;
}
.sidebar {
height: auto;
width: 30vh;
display: flex;
flex-direction: column;
justify-content: left;
}
input {
padding-inline: 5px;
outline: none;
height: 5vh;
border-style: inset;
border-color: var(--primary-color);
border-radius: 2vh;
}
.add-twitt {
padding: 1vh;
border-style: groove;
border-width: 2px;
border-radius: 10px;
border-color: black;
height: 15vh;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.input_twitt {
outline: none;
border-style: none;
width: 90%;
word-wrap: break-word;
resize: none;
}
.add-btn {
place-self: center;
cursor: pointer;
width: 15vh;
height: 5vh;
color: white;
font-size: 1.2rem;
background-color: var(--primary-color);
border-radius: 1vh;
border-style: none;
}
App.css
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: white;
min-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: #282c34;
}
.App-link {
color: #61dafb;
}
index.css
:root {
--primary-color: #3366ff;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
a {
text-decoration: none;
}
h2 {
margin: 0;
}
.main {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.container {
width: 80%;
margin-top: 5vh;
display: flex;
align-items: center;
justify-content: space-between;
}
Спасибо за уделенное время, надеюсь статья была полезна и понятна.