Request Download 페이지 구현

이번에 꾸며볼 페이지는 다음과 같은 Downloads 요청 페이지입니다.


DreamStory 컨트랙트에서 다음과 같이 구조체와 이 구조체를 배열로 저장하는 상태변수를 만들어 두었습니다.

// download struct
    struct Download {
        // address of the downloder
        address downloader;
        // download price in wei
        uint price_wei;
        // download date
        uint date;
    }

    // download history
    Download[] public downloads;

다운로드한 계정, 금액, 시간(정확히는 블록타임)을 저장하는 구조체 배열입니다. contributor가 다운로드 요청하면 그 기록을 남기는 것입니다. 그리고 View Downloads 페이지에서 그 이력들을 보여줄 것입니다. 그런데 한 가지 문제가 있습니다.

다운로드 페이지를 꾸미기 위해서는 단순히 downloads 배열만 리턴하면 될텐데, 현실은 그리 녹녹치 않습니다. 알아보니 현재 솔리디티는 구조체 배열을 리턴할 수 없습니다. 향후에는 지원할 거라는 말은 보입니다만, 지금은 안 되나 봅니다.

그래서 부득이하게 스마트 컨트랙트 소스 코드를 변경해야 합니다. 앞서 스마트 컨트랙트 수정할 것도 같이 반영하여 컴파일, 배포하도록 할 것입니다.


컨트랙트 수정 및 재배포

수정할 것은 별거 없습니다. 구조체 배열 리턴이 안 되기 때문에 배열의 index를 이용하여 하나하나 배열 요소에 접근하는 방식을 택할 것입니다. 그러기 위해서 배열의 크기를 알아야 합니다. 사실 getSummary 함수에서 downloads 배열의 크기를 리턴하긴 하지만, 값 하나만 필요한데 다른 값들까지 리턴할 필요는 없어서 별도의 함수를 만들도록 합니다. 다음과 같이 DreamFactory.sol 파일에서 DreamStory Contract에 함수를 추가합니다. 그저 배열의 크기를 반환하는 함수입니다.

    /*
     * Get the number of downloads
     * @return the length of downloads instance
     */
    function getDownloadsCount() public view returns (uint) {
        return downloads.length;
    }


앞서 수정해야 했던 contribute 함수도 다음과 같이 수정합니다.

function contribute() public payable {
        // check if the money is greater than zero
        require( msg.value > 0 );
        // increase the vote counts only if the sender is in the contributor list
        if( !contributors[msg.sender] )
        {
           votes_count++;
           // set contributor address to true
           contributors[ msg.sender ]= true;
        }
    }


ethereum 폴더로 이동하여 다음과 같이 compile.js와 deploy.js를 실행합니다.

$ node compile.js
$ node deploy.js

반드시 compile.js의 결과를 확인하셔야 합니다. 혹시 에러가 발생한다면, Remix로 컨트랙트 코드를 복사하여 에러를 확인하고 수정해야 합니다.


다음으로 dream_factory.js에서 컨트랙트의 주소를 새로 배포된 주소로 변경합니다. 컨트랙트 몇 번 배포해 보니 어떤 어떤 작업을 해야 하는지 이제 좀 감이 오시죠?

새로 컨트랙트를 배포했기 때문에 이전에 생성했던 DreamStory 컨트랙트는 사라졌습니다. 이렇게요.


완전히 사라진건 아니고요. 컨트랙트 주소만 알고 있으면 언제든지 접근 가능합니다. 이럴 땐 Remix가 용이하겠죠? 동일한 story를 입력하려고 Remix로 예전 컨트랙트에 접근하여 getSummary 함수로 내용을 읽어 봅니다.


이제 이정도의 작업은 익숙하시죠?

새로 배포한 컨트랙트에 접근하여 "Create a Story"를 클릭하여 스토리를 만들고, contribute도 합니다. 그런 후에 다음과 같이 Request Download 컴포넌트 구성을 합니다.


컴포넌트 구성

앞의 화면을 보면 이미 이전에 다 구성했던 컴포넌트입니다. 특히 story_details 페이지와 매우 유사합니다. 여기서는 그저 필요한 것만 쏙쏙 가져와서 꾸미면 되겠습니다. 왜 story_details 페이지에서 바로 다운로드하는 기능을 넣지 않느냐 하고 궁금해 하시는 분이 있을 것입니다. 저도 그렇고 싶었는데, 일단 Form 태그에 이벤트 핸들러를 2개 연결해야 하는데, 그게 잘 안됐고 Form 태그를 2개 쓰려고 했더니 또 태그를 연달아 쓸 수 없다나 뭐라나 해서 그냥 원래 의도대로 분리하기로 했습니다. 아시는 분은 댓글 남겨주시면 고맙겠습니다.

구성은 story_details 페이지와 거의 같고 버튼 이름과 몇 가지 레이블 이름만 변경했습니다. 이렇게요.


한가지 추가한 것은 Story 작가의 balance를 표시했습니다. 작가의 잔고를 보면 인기 작가인지 아닌지 알 수 있을거 같네요.

request_download 페이지의 소스 코드에 대한 설명은 따로 하지 않겠습니다. story_detail 페이지 내용을 참고하세요.

import React, {Component } from 'react';
// import Form, Button from semantic-ui-react
import { Card, Icon } from 'semantic-ui-react';
// import Grid, Input, Form, Message, Button
import { Grid, Input, Form, Message, Button } from 'semantic-ui-react';
// import Container and Header
import { Container, Header } from 'semantic-ui-react';
// import Link, Router
import { Link, Router } from '../../routes';
// import layout
import Layout from '../../components/layout';
// import DreamStory instance
import dream_story from '../../ethereum/dream_story';
// import web3
import web3 from '../../ethereum/web3';

class RequestDownload extends Component {
  // state for form inputs
  state = {
    down_price: '',
    error_msg: '',
    loading: false
  };

  // get initial properties
  // the DreamStory address can be obtained from the argument props using the url
  // since the url includes the contract address
  static async getInitialProps( props ) {
    // get the DreamStory instance of the address
    const story= dream_story( props.query.address );
    // get summary of the story
    const summary= await story.methods.getSummary().call();
    // get author's balance
    const author_balance= await web3.eth.getBalance( summary[5] );
    // return the summary with labels
    return {
      address: props.query.address,
      balance: web3.utils.fromWei( summary[0], 'ether' ),
      author_balance: web3.utils.fromWei( author_balance, 'ether' ),
      votes_count: summary[1],
      downloads_count: summary[2],
      min_down_price: web3.utils.fromWei( summary[3], 'ether' ),
      approvers_count: summary[4],
      author: summary[5],
      story_title: summary[6],
      story: summary[7]
    };
  }

  // event handler for download button
  onDownload = async () => {
    // block default submitting the form
    event.preventDefault();
    // set button loading and clear error message
    this.setState({ loading: true, error_msg: '' });
    // catch any error while executing the following
    try {
      // get all accounts of a user and use the accounts[0] to download
      const accounts= await web3.eth.getAccounts();
      // get the DreamStory instance of the address
      const story= dream_story( this.props.address );
      // convert download price to wei
      const down_price_wei= web3.utils.toWei( this.state.down_price, 'ether' );
      // call download function using the user's first account
      // use metamask's functinality to estimate the gas limit
      await story.methods.download()
      .send({
        from: accounts[0],
        value: down_price_wei
      });
      // redirect to the download list page
      Router.replaceRoute(`/dream_stories/${this.props.address}/downloads_list`);
    } catch (error) {
      this.setState( { error_msg: error.message } );
    }
    // clear loading
    this.setState({ loading: false });
  };

  renderActionButtons() {
    return (
      <Form onSubmit={this.onDownload} error={!!this.state.error_msg}>
        <Form.Field>
          <label>Amount to pay for Download</label>
          <Input
            label="ether"
            labelPosition="right"
            placeholder='0.001'
            value={ this.state.down_price }
            onChange={ event => this.setState( { down_price: event.target.value } ) }
          />
        </Form.Field>
        <Message error header="Failed!" content={ this.state.error_msg } />
        <Button loading={this.state.loading} primary>Download</Button>
        <p></p>
        <Link route={`/dream_stories/${this.props.address}/downloads_list`}>
          <a>
            <Button primary>View Downloads</Button>
          </a>
        </Link>
      </Form>
    );
  }

  // render
  render() {
    return (
      <Layout>
        <h2>Request Download</h2>
        <Grid>
          <Grid.Column width={10}>
            <Container text>
              <Header as='h3'>{this.props.story_title}</Header>
              <p>{ this.props.story }</p>
            </Container>
          </Grid.Column>
          <Grid.Column width={6}>
            <Card>
              <Card.Content header='Story Statistics' />
              <Card.Content extra>
                <Icon name='dollar sign' />
                {this.props.author_balance} (author balance)
              </Card.Content>
              <Card.Content extra>
                <Icon name='dollar sign' />
                {this.props.balance} (balance)
              </Card.Content>
              <Card.Content extra>
                <Icon name='user' />
                {this.props.votes_count} (votes)
              </Card.Content>
              <Card.Content extra>
                <Icon name='download' />
                {this.props.downloads_count} (downloads)
              </Card.Content>
              <Card.Content extra>
                <Icon name='cart arrow down' />
                {this.props.min_down_price} (minimum download price )
              </Card.Content>
            </Card>
            { this.renderActionButtons() }
          </Grid.Column>
        </Grid>
      </Layout>
    );
  }
}

export default RequestDownload;

한 가지 다른 점은 Download 트랜잭션이 완료되면 새로고침하는 것이 아니라 View Downloads 페이지로 이동하게끔 라우팅했습니다.


(생략)
await story.methods.download()
.send({
  from: accounts[0],
  value: down_price_wei
});
// redirect to the download list page
Router.replaceRoute(`/dream_stories/${this.props.address}/downloads_list`);
(생략)


오늘의 실습: 꿈일기를 다운로드(저작권)해서 어디에 쓸 수 있을까요?



 [한빛미디어 블록체인 도서 보러가기]