안경잡이개발자

728x90
반응형

  이번 시간에는 필터(Filter) 함수를 이용하여 고객(Customer) 검색 기능을 구현하는 방법에 대해서 알아보도록 하겠습니다.


▶ App.js


  본적으로 검색 창에 사용자가 입력한 문자열을 스테이트(State)에 반영하기 위해서는 다음과 같은 값 변경 처리 함수가 필요합니다. 바로 함수를 만들어 보도록 하겠습니다.


handleValueChange(e) {
let nextState = {};
nextState[e.target.name] = e.target.value;
this.setState(nextState);
}


  이후에 생성자(Constructor)에서 이를 바인딩(Binding) 처리해주시면 됩니다.


constructor(props) {
super(props);
this.state = {
customers: '',
completed: 0,
searchKeyword: ''
}
this.stateRefresh = this.stateRefresh.bind(this);
this.handleValueChange = this.handleValueChange.bind(this)
}


  이후에 <InputBase>에서 다음과 같이 작성합니다.


<InputBase
placeholder="검색하기"
classes={{
root: classes.inputRoot,
input: classes.inputInput,
}}
name="searchKeyword"
value={this.state.searchKeyword}
onChange={this.handleValueChange}
/>


  지금까지는 단순히 고객 스테이트가 변경되면 전체 고객을 바로 보여주는 식으로 코드가 동작했습니다. 이제는 전체 고객 컴포넌트를 생성하는 부분을 render() 함수의 가장 윗 부분에서 새롭게 명시해주도록 하겠습니다.


const filteredComponents = (data) => {
data = data.filter((c) => {
return c.name.indexOf(this.state.searchKeyword) > -1;
});
return data.map((c) => {
return <Customer stateRefresh={this.stateRefresh} key={c.id} id={c.id} image={c.image} name={c.name} birthday={c.birthday} gender={c.gender} job={c.job} />
});
}


  이후에 <TableBody> 부분을 다음과 같이 작성하면 됩니다.


<TableBody>
{this.state.customers ?
filteredComponents(this.state.customers) :
<TableRow>
<TableCell colSpan="6" align="center">
<CircularProgress className={classes.progress} variant="determinate" value={this.state.completed} />
</TableCell>
</TableRow>
}
</TableBody>


  마지막으로 새로운 고객이 추가된 경우 검색 창 내용도 비워질 수 있도록 처리하겠습니다.


stateRefresh() {
this.setState({
customers: '',
completed: 0,
searchKeyword: ''
});
this.callApi()
.then(res => this.setState({customers: res}))
.catch(err => console.log(err));
}


※ 실행 결과 ※



728x90
반응형

728x90
반응형

  지금까지 강의를 잘 따라오신 여러분들은 다음과 같이 완성된 프로젝트를 확인할 수 있게 될 거예요.


※ 완성된 프로젝트 ※



※ 복습하기 ※


  프로젝트를 완성해 나가면서 배웠던 큼직한 요소들에 대해서 복습을 해봅시다!


1) React 및 파이어베이스 호스팅


  본 강좌의 핵심이었던 React 개발 및 파이어베이스 호스팅에 대해서 잊으시면 안 돼요. React에서 다루는 각 데이터를 파이어베이스에 저장하고 관리했으며 파이어베이스 호스팅 서비스를 이용해 웹 사이트를 운영할 수 있었어요.



2) 플라스크(Flask) API 서버 구축


  이후에 우리는 AWS EC2를 이용하여 플라스크에 워드 클라우드 API를 달아서 구동시켰습니다. 프리 티어를 이용해 무료로 서버를 구축하고, 이에 웹 서버를 올리는 방식으로 효과적으로 API를 구축했답니다.



3) 클라우드 플레어(Cloud Flare) HTTPS 서비스


  이후에는 도메인 주소를 생성하고, 클라우드 플레어 서비스를 이용해서 우리의 API 서버에 HTTPS를 입히는 과정을 거쳤어요.



4) 파이어베이스(Firebase)


  파이어베이스(Firebase)의 데이터베이스 서비스를 활용하는 방법에 대해서도 공부하는 시간을 가졌습니다.



5) 안드로이드 스튜디오(Android Studio)


  우리는 최종적으로 만들어진 웹을 앱 형태로 배포하기 위하여 안드로이드 스튜디오를 이용해 웹 앱을 개발하고, APK 파일을 추출했어요.



728x90
반응형

728x90
반응형

  이번 시간에는 안드로이드 스튜디오(Android Studio)를 이용하여 현재까지 만들었던 웹 애플리케이션을 앱(App) 형태로 배포하도록 하겠습니다. 따라서 안드로이드 스튜디오를 실행하여 프로젝트를 생성하도록 하겠습니다.




  이후에 빈 액티비티 형태로 프로젝트를 구성합니다.




  이후에 다음과 같은 3개의 소스코드를 작업하시면 됩니다.


  먼저 레이아웃부터 작업해주시면 됩니다.


▶ activity_main.xml


<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

</android.support.constraint.ConstraintLayout>


▶ MainActivity.java

package tk.wordcloudpython.wordcloud;

import android.content.Intent;
import android.net.Uri;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;

public class MainActivity extends AppCompatActivity {

private WebView webView;
private ValueCallback<Uri> filePathCallbackNormal;
private ValueCallback<Uri[]> filePathCallbackLollipop;
private final static int FILE_CHOOSER_NORMAL_REQ_CODE = 1;
private final static int FILE_CHOOSER_LOLLIPOP_REQ_CODE = 2;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
webView = (WebView) findViewById(R.id.webView);
/* 웹 세팅 작업하기 */
WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true);
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
webSettings.setAllowFileAccessFromFileURLs(true);
webSettings.setSaveFormData(false);
webSettings.setSavePassword(false);
webSettings.setUseWideViewPort(true);
webSettings.setSupportMultipleWindows(true);
webSettings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.SINGLE_COLUMN);
/* 리다이렉트 할 때 브라우저 열리는 것 방지 */
webView.setWebViewClient(new WebViewClient());
webView.setWebChromeClient(new WebChromeClient() {
// 안드로이드 3.0 이상
public void openFileChooser( ValueCallback<Uri> uploadMsg, String acceptType) {
filePathCallbackNormal = uploadMsg;
Intent i = new Intent(Intent.ACTION_GET_CONTENT);
i.addCategory(Intent.CATEGORY_OPENABLE);
i.setType("text/plain");
startActivityForResult(Intent.createChooser(i, "File Chooser"), FILE_CHOOSER_NORMAL_REQ_CODE);
}
// 안드로이드 4.1 이상
public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType, String capture) {
openFileChooser(uploadMsg, acceptType);
}
// 안드로이드 5.0 이상
public boolean onShowFileChooser(
WebView webView, ValueCallback<Uri[]> filePathCallback,
WebChromeClient.FileChooserParams fileChooserParams) {
if (filePathCallbackLollipop != null) {
filePathCallbackLollipop.onReceiveValue(null);
filePathCallbackLollipop = null;
}
filePathCallbackLollipop = filePathCallback;
Intent i = new Intent(Intent.ACTION_GET_CONTENT);
i.addCategory(Intent.CATEGORY_OPENABLE);
i.setType("text/plain");
startActivityForResult(Intent.createChooser(i, "File Chooser"), FILE_CHOOSER_LOLLIPOP_REQ_CODE);
return true;
}
});
/* 웹 뷰 띄우기 */
webView.loadUrl("https://wordcloud-e11f9.firebaseapp.com/");
}

public void onBackPressed() {
if (webView.canGoBack()) webView.goBack();
else finish();
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (resultCode == RESULT_OK) {
if (requestCode == FILE_CHOOSER_NORMAL_REQ_CODE) {
if (filePathCallbackNormal == null) return;
Uri result = (data == null || resultCode != RESULT_OK)? null : data.getData();
filePathCallbackNormal.onReceiveValue(result);
filePathCallbackNormal = null;
} else if (requestCode == FILE_CHOOSER_LOLLIPOP_REQ_CODE) {
if (filePathCallbackLollipop == null) return;
filePathCallbackLollipop.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(resultCode, data));
filePathCallbackLollipop = null;
}
} else {
if (filePathCallbackLollipop != null) {
filePathCallbackLollipop.onReceiveValue(null);
filePathCallbackLollipop = null;
}
}
}
}


▶ AndroidManifest.xml


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="tk.wordcloudpython.wordcloud">

<uses-permission android:name="android.permission.INTERNET"/>

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.NoActionBar">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>


  이제 안드로이드 애뮬레이터를 활용하여 프로젝트를 테스트 해보겠습니다.







  다만 파일 선택을 하고 싶어도 다음과 같이 파일이 존재하지 않는 것을 확인할 수 있습니다.



  따라서 안드로이드 애뮬레이터로 파일을 옮기기 위해서 일단 하나의 텍스트 파일을 생성하겠습니다.



  따라서 안드로이드 스튜디오에서 오른쪽 아래의 [Device File Explorer] 탭을 열어서 [sdcard] 폴더에 텍스트 파일을 넣어주시면 됩니다.



  우클릭 이후에 업로드(Upload)를 진행하시면 됩니다.




  이제 다시 텍스트 파일 업로드를 진행하시면 됩니다.




  다음과 같이 텍스트 파일을 추가해보도록 하겠습니다.



  이제 추가된 텍스트 파일을 확인해보도록 하겠습니다.




  성공적으로 워드 클라우드가 생성되는 것을 확인할 수 있습니다.




※ APK 파일 배포하기 ※


  실제로 구글 플레이스토어 마켓 등에 배포하기 위해서는 APK 파일로 추출하는 과정이 필요합니다. 안드로이드 스튜디오를 활용해 다음과 같은 과정으로 APK 파일을 추출할 수 있습니다. 가장 먼저 [Build] 탭에서 APK 파일을 추출하는 버튼을 누르시면 됩니다.



  저는 다음과 같이 앱 번들을 이용하지 않고 단순 APK 파일로 추출하도록 하겠습니다.



  먼저 APK 파일을 만들기 위해서는 먼저 키 페어를 생성해주셔야 합니다.



  저는 다음과 같은 경로에 키를 만들어 줄 거예요.



  키를 생성할 때는 키를 생성한 사람에 대한 서명 정보를 기입해주셔야 합니다.



  이후에 다음과 같이 해당 키로 APK 파일을 생성해주시면 됩니다.




  약간의 시간이 흐른 뒤에 빌드(Build)가 끝나면 릴리즈 된 APK 파일을 확인하실 수 있습니다.




※ 깃에 업로드하기 ※


  안드로이드 프로젝트를 깃에 업로드하여 관리하는 방법 또한 알아보도록 하겠습니다.



  깃 프로젝트를 설정할 때는 루트 프로젝트를 설정하시면 됩니다.



  이후에 ADD 및 COMMIT을 진행하시면 됩니다.





  커밋을 하실 때에는 [Unversioned Files]로 표시되어 있는 프로젝트의 환경 설정 파일들도 함께 커밋해주세요.



  커밋 이후에는 푸시를 진행하시면 됩니다.



  푸시를 하실 때에는 깃 허브(Git Hub)의 원격지 주소로 푸시하시면 됩니다. 따라서 [Define Remote]를 선택합니다.



  이후에 깃 허브(Git Hub)에 하나의 리포지터리를 만들어 주시고, 이를 넣어주시면 됩니다.




  전체 파일들을 다 푸시해주시면 다음과 같이 깃 허브에 프로젝트가 등록되는 것을 확인할 수 있습니다.



※ 완성된 프로젝트를 클론(Clone)하는 방법 ※


  이후에 완성된 프로젝트를 다른 개발 환경에서 클론하고자 할 때는 다음과 같이 해주시면 됩니다.



  클론 받기 위한 주소를 넣고, 특정한 폴더로 프로젝트를 다운로드 해주세요.




  이후에 다음과 같이 클론 된 소스코드를 통해 프로젝트를 구성하시면 됩니다.




  기본적으로 계속 다음(Next) 버튼을 누르시면 됩니다.



  다음과 같이 프로젝트 클론(Clone)까지 다루어 보았습니다.


728x90
반응형

728x90
반응형

※ 기존 소스코드의 문제점 확인 ※


  가장 먼저 리액트(React) 소스코드에서 API 경로를 AWS EC2의 경로로 바꾸어보도록 하겠습니다.



  이후에 기존에 존재하던 build 폴더를 제거한 뒤에 다시 yarn build 명령어를 수행합니다.



  그리고 firebase deploy 명령어를 입력하여 배포를 진행하도록 하겠습니다.



  이후에 배포된 경로로 접속하면 [워드 클라우드]를 생성하고자 할 때 다음과 같이 HTTPS 미설정 오류 메시지가 출력됩니다. 이러한 오류 메시지는 파이어베이스가 HTTPS를 지원하지만 우리의 API 서버는 HTTPS를 지원하지 않기 때문에 발생합니다. 따라서 우리의 플라스크(Flask) 웹 서버도 HTTPS를 지원하도록 처리해야 합니다.



※ FREENOM 서비스를 이용해 무료 도메인 사용하기 ※


  ▶ 프리넘 서비스: https://freenom.com


  프리넘 서비스는 무료로 도메인을 사용할 수 있도록 해줍니다. 결과적으로 우리가 HTTPS를 사용하기 위한 초석이 되어주므로 일단 도메인부터 구매해보도록 하겠습니다. 저는 회원가입 및 로그인 이후에 wordcloudpython라는 도메인을 사용하겠다고 검색해보도록 하겠습니다.



  이후에 다음과 같이 .tk 도메인을 채택하겠습니다.



  저는 다음과 같이 무료로 도메인을 사용해보도록 하겠습니다.




  구매 이후에는 다음과 같이 [My Domains] 탭에서 자신이 구매한 도메인을 관리할 수 있습니다.



  다음과 같이 타겟(Target) 주소로 우리의 AWS EC2 서버 주소를 입력해주시면 됩니다.



  이후에는 통상적으로 약 5분 뒤에 도메인 설정이 완료됩니다. 다음과 같이 해당 도메인으로 접속했을 때 플라스크 API가 동작합니다.



※ 도메인에 HTTPS를 적용하기 위해 Cloud Flare 사용하기 ※


  ▶ Cloud Flare 가입: https://dash.cloudflare.com/sign-up


  클라우드 플레어에 가입한 이후에 HTTPS를 적용할 수 있습니다.



  이후에 다음과 같이 우리가 사용하게 된 무료 도메인을 사이트(Site)로 추가하시면 됩니다.



  이후에 클라우드 플레어가 자동으로 DNS 레코드를 얻어오게 됩니다. [Next] 버튼을 눌러주세요.



  이후에 무료로 서비스를 이용하기 위해 무료 플랜(FREE PLAN)을 설정해주겠습니다.



  이후에 다음과 같이 wordcloudpython.tk라는 우리의 도메인을 52.79.163.195라는 AWS EC2 IP 주소로 연결해주었습니다. 이로써 결과적으로 Cloud Flare의 DNS 서버에 wordcloudpython.tk라는 쿼리가 들어오면 52.79.163.195로 연결을 진행해주는 것입니다.



  이후에 다음과 같이 기존의 네임 서버(Name Server)를 바꾸어주라는 메시지가 나오게 됩니다. 다음의 그림에서 요구하는 대로 FREENOM 서비스의 네임 서버를 매핑해주시면 됩니다.



  따라서 다음과 같이 [Nameservers] 탭에서 커스텀 네임 서버(Custom Name Server)로 클라우드 플레어의 DNS 주소를 넣어주시면 됩니다.



  이후에 Cloud Flare에서 DNS 설정이 완료될 때까지 기다리시면 됩니다. [Re-check now] 버튼을 누른 뒤에 약간의 시간을 기다리시면 됩니다.



  결과적으로 다음과 같이 설정이 완료되면 이메일로 설정 완료 메시지가 나온다고 하네요. 일반적으로 1시간 안에 설정이 완료됩니다.




  결과적으로 잠시 후에 Cloud Flare에서 새로고침을 해보시면 설정이 완료되어 정상적으로 동작하고 있다는 것을 확인할 수 있습니다.



  기본적으로 Cloud Flare는 HTTPS로 요청(Request)을 받아서 우리의 AWS EC2 서버의 80번 포트로 패킷을 전달해주게 됩니다. 따라서 이제 우리는 플라스크(Flask) 웹 서버를 80번 포트로 열어야 합니다. 그러므로 보안 그룹에서 80번 포트를 허용해주시면 됩니다.



  당연히 플라스크의 소스코드도 80번 포트를 이용하도록 수정하셔야 해요.



  결과적으로 다음과 같이 80번 포트로 동작하도록 서버를 구동시키면 됩니다.



※ HTTPS 접속 확인 ※


  결과적으로 다음과 같이 HTTPS로 접속을 해보시면 성공적으로 플라스크 서버가 동작하는 것을 확인할 수 있습니다.



  따라서 이제 API 서버의 URL을 새로운 경로로 바꾸어 다시 배포해주시면 됩니다.





  최종적으로 AWS EC2에서 플라스크 웹 서버를 다시 구동시켜주도록 하겠습니다.



  이후에 다음과 같이 성공적으로 호스팅된 파이어베이스 서버에서 API가 정상적으로 동작하는 것을 확인할 수 있습니다.


728x90
반응형

728x90
반응형

  이번 시간에는 만들어진 워드 클라우드(Word Cloud) API를 웹 상에서 접근할 수 있는 방법에 대해서 알아보도록 하겠습니다. 웹 상에서 특정한 이미지 파일 등에 접근하기 위해서는 정적 폴더(Static Folder)를 설정해야 합니다.


▶ word_cloud.py


# 단어구름에 필요한 라이브러리를 불러옵니다.
from wordcloud import WordCloud
# 한국어 자연어 처리 라이브러리를 불러옵니다.
from konlpy.tag import Twitter
# 명사의 출현 빈도를 세는 라이브러리를 불러옵니다.
from collections import Counter
# 그래프 생성에 필요한 라이브러리를 불러옵니다.
import matplotlib.pyplot as plt
# Flask 웹 서버 구축에 필요한 라이브러리를 불러옵니다.
from flask import Flask, request, jsonify

# 플라스크 웹 서버 객체를 생성합니다.
app = Flask(__name__, static_folder='outputs')

# 폰트 경로 설정
font_path = 'NanumGothic.ttf'


def get_tags(text, max_count, min_length):
# 명사만 추출합니다.
t = Twitter()
nouns = t.nouns(text)
processed = [n for n in nouns if len(n) >= min_length]
# 모든 명사의 출현 빈도를 계산합니다.
count = Counter(processed)
result = {}
# 출현 빈도가 높은 max_count 개의 명사만을 추출합니다.
for n, c in count.most_common(max_count):
result[n] = c
# 추출된 단어가 하나도 없는 경우 '내용이 없습니다.'를 화면에 보여줍니다.
if len(result) == 0:
result["내용이 없습니다."] = 1
return result


def make_cloud_image(tags, file_name):
# 만들고자 하는 워드 클라우드의 기본 설정을 진행합니다.
word_cloud = WordCloud(
font_path=font_path,
width=800,
height=800,
background_color="white",
)
# 추출된 단어 빈도수 목록을 이용해 워드 클라우드 객체를 초기화 합니다.
word_cloud = word_cloud.generate_from_frequencies(tags)
# 워드 클라우드를 이미지로 그립니다.
fig = plt.figure(figsize=(10, 10))
plt.imshow(word_cloud)
plt.axis("off")
# 만들어진 이미지 객체를 파일 형태로 저장합니다.
fig.savefig("outputs/{0}.png".format(file_name))


def process_from_text(text, max_count, min_length, words, file_name):
# 최대 max_count 개의 단어 및 등장 횟수를 추출합니다.
tags = get_tags(text, max_count, min_length)
# 단어 가중치를 적용합니다.
for n, c in words.items():
if n in tags:
tags[n] = tags[n] * int(words[n])
# 명사의 출현 빈도 정보를 통해 워드 클라우드 이미지를 생성합니다.
make_cloud_image(tags, file_name)


@app.route("/process", methods=['GET', 'POST'])
def process():
content = request.json
words = {}
if content['words'] is not None:
for data in content['words'].values():
words[data['word']] = data['weight']
process_from_text(content['text'], content['maxCount'], content['minLength'], words, content['textID'])
result = {'result': True}
return jsonify(result)


@app.route('/outputs', methods=['GET', 'POST'])
def output():
text_id = request.args.get('textID')
return app.send_static_file(text_id + '.png')


if __name__ == '__main__':
app.run('0.0.0.0', port=5000)


▶ 이미지 생성


  이미지 생성에는 textID라는 파라미터를 추가적으로 넣어주세요.



▶ 이미지 접근


  이미지에 접근할 때는 다음과 같이 파라미터로 접근하면 됩니다.



▶ word_cloud.py


  이러한 이미지에 실제로 클라이언트에서 효과적으로 접근할 수 있도록 이미지가 존재하는지 확인하기 위한 validate() 함수를 추가적으로 만들어 주었습니다.


# 단어구름에 필요한 라이브러리를 불러옵니다.
from wordcloud import WordCloud
# 한국어 자연어 처리 라이브러리를 불러옵니다.
from konlpy.tag import Twitter
# 명사의 출현 빈도를 세는 라이브러리를 불러옵니다.
from collections import Counter
# 그래프 생성에 필요한 라이브러리를 불러옵니다.
import matplotlib.pyplot as plt
# Flask 웹 서버 구축에 필요한 라이브러리를 불러옵니다.
from flask import Flask, request, jsonify
# 테스트를 위하여 CORS를 처리합니다.
from flask_cors import CORS
# 파일에 접근하기 위한 라이브러리를 불러옵니다.
import os

# 플라스크 웹 서버 객체를 생성합니다.
app = Flask(__name__, static_folder='outputs')
CORS(app)

# 폰트 경로 설정
font_path = 'NanumGothic.ttf'


def get_tags(text, max_count, min_length):
# 명사만 추출합니다.
t = Twitter()
nouns = t.nouns(text)
processed = [n for n in nouns if len(n) >= min_length]
# 모든 명사의 출현 빈도를 계산합니다.
count = Counter(processed)
result = {}
# 출현 빈도가 높은 max_count 개의 명사만을 추출합니다.
for n, c in count.most_common(max_count):
result[n] = c
# 추출된 단어가 하나도 없는 경우 '내용이 없습니다.'를 화면에 보여줍니다.
if len(result) == 0:
result["내용이 없습니다."] = 1
return result


def make_cloud_image(tags, file_name):
# 만들고자 하는 워드 클라우드의 기본 설정을 진행합니다.
word_cloud = WordCloud(
font_path=font_path,
width=800,
height=800,
background_color="white",
)
# 추출된 단어 빈도수 목록을 이용해 워드 클라우드 객체를 초기화 합니다.
word_cloud = word_cloud.generate_from_frequencies(tags)
# 워드 클라우드를 이미지로 그립니다.
fig = plt.figure(figsize=(10, 10))
plt.imshow(word_cloud)
plt.axis("off")
# 만들어진 이미지 객체를 파일 형태로 저장합니다.
path = "outputs/{0}.png".format(file_name)
# 이미 파일이 존재하는 경우 덮어쓰기합니다.
if os.path.isfile(path):
os.remove(path)
fig.savefig(path)


def process_from_text(text, max_count, min_length, words, file_name):
# 최대 max_count 개의 단어 및 등장 횟수를 추출합니다.
tags = get_tags(text, max_count, min_length)
# 단어 가중치를 적용합니다.
for n, c in words.items():
if n in tags:
tags[n] = tags[n] * int(words[n])
# 명사의 출현 빈도 정보를 통해 워드 클라우드 이미지를 생성합니다.
make_cloud_image(tags, file_name)


@app.route("/process", methods=['GET', 'POST'])
def process():
content = request.json
words = {}
if content['words'] is not None:
for data in content['words'].values():
words[data['word']] = data['weight']
process_from_text(content['text'], content['maxCount'], content['minLength'], words, content['textID'])
result = {'result': True}
return jsonify(result)


@app.route('/outputs', methods=['GET', 'POST'])
def output():
text_id = request.args.get('textID')
return app.send_static_file(text_id + '.png')


@app.route('/validate', methods=['GET',' POST'])
def validate():
text_id = request.args.get('textID')
path = "outputs/{0}.png".format(text_id)
result = {}
# 해당 이미지 파일이 존재하는지 확인합니다.
if os.path.isfile(path):
result['result'] = True
else:
result['result'] = False
return jsonify(result)


if __name__ == '__main__':
app.run('0.0.0.0', port=5000, threaded=True) # 처리 속도 향상을 위해 쓰레드를 적용합니다.

▶ Detail.js


  이제 Detail.js 파일을 수정합니다. 기본적으로 React에서 이미지(Image) 태그의 정보가 수정될 때에는 브라우저 캐시(Cache)가 동작하지 않도록, 별도의 랜덤 파라미터를 추가적으로 붙여 줄 수 있습니다.


import React from 'react';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import '../index.css';
import { withStyles } from '@material-ui/core/styles';
import Fab from '@material-ui/core/Fab';
import UpdateIcon from '@material-ui/icons/Update';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogTitle from '@material-ui/core/DialogTitle';
import Button from '@material-ui/core/Button';

const databaseURL = "https://react-example-55161.firebaseio.com/";
const apiURL = "http://localhost:5000";

const styles = theme => ({
fab: {
position: 'fixed',
bottom: '20px',
right: '20px'
},
});


class Detail extends React.Component {

constructor(props) {
super(props);
this.state = {
dialog: false,
textContent: '',
words: {},
imageUrl: null
}
}

componentDidMount() {
this._getImage();
this._getText();
this._getWords();
}

_getText() {
fetch(`${databaseURL}/texts/${this.props.match.params.textID}.json`).then(res => {
if(res.status != 200) {
throw new Error(res.statusText);
}
return res.json();
}).then(text => this.setState({textContent: text['textContent']}));
}

_getWords() {
fetch(`${databaseURL}/words.json`).then(res => {
if(res.status != 200) {
throw new Error(res.statusText);
}
return res.json();
}).then(words => this.setState({words: (words == null) ? {} : words}));
}

_getImage() {
fetch(`${apiURL}/validate?textID=${this.props.match.params.textID}`).then(res => {
if(res.status != 200) {
throw new Error(res.statusText);
}
return res.json();
}).then(data => {
if(data['result'] == true) {
this.setState({imageUrl: apiURL + "/outputs?textID=" + this.props.match.params.textID})
} else {
this.setState({imageUrl: 'NONE'});
}
});
}

handleDialogToggle = () => this.setState({
dialog: !this.state.dialog
})

handleSubmit = () => {
this.setState({imageUrl: 'READY'});
const wordCloud = {
textID: this.props.match.params.textID,
text: this.state.textContent,
maxCount: 30,
minLength: 1,
words: this.state.words
}
this.handleDialogToggle();
if (!wordCloud.textID ||
!wordCloud.text ||
!wordCloud.maxCount ||
!wordCloud.minLength ||
!wordCloud.words) {
return;
}
this._post(wordCloud);
}
_post = (wordCloud) => {
return fetch(`${apiURL}/process`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(wordCloud)
}).then(res => {
if(res.status != 200) {
throw new Error(res.statusText);
}
return res.json();
}).then(data => {
this.setState({imageUrl: apiURL + "/outputs?textID=" + this.props.match.params.textID})
});
}

render() {
const { classes } = this.props;
return (
<div>
<Card>
<CardContent>
{
(this.state.imageUrl)?
((this.state.imageUrl == 'READY')?
'워드 클라우드 이미지를 불러오고 있습니다.':
((this.state.imageUrl == 'NONE')?
'해당 텍스트에 대한 워드 클라우드를 만들어 주세요.':
<img key={Math.random()} src={this.state.imageUrl + '&random=' + Math.random()} style={{width: '100%'}}/>)):
''
}
</CardContent>
</Card>
<Fab color="primary" className={classes.fab} onClick={this.handleDialogToggle}>
<UpdateIcon />
</Fab>
<Dialog open={this.state.dialog} onClose={this.handleDialogToggle}>
<DialogTitle>워드 클라우드 생성</DialogTitle>
<DialogActions>
<Button variant="contained" color="primary" onClick={this.handleSubmit}>
{(this.state.imageUrl == 'NONE')? '만들기' : '다시 만들기'}
</Button>
<Button variant="outlined" color="primary" onClick={this.handleDialogToggle}>닫기</Button>
</DialogActions>
</Dialog>
</div>
);
}
}

export default withStyles(styles)(Detail);


※ 실행 결과 ※


  먼저 하나의 텍스트를 추가해보도록 하겠습니다.




  이어서 만들어진 텍스트를 확인해보도록 하겠습니다.



  처음에는 워드 클라우드가 존재하지 않는 상황입니다.



  워드 클라우드를 생성한 이후에는 다음과 같이 정상적으로 출력됩니다.




  또한 만들어진 이후에 [다시 만들기] 버튼을 누르면 새롭게 워드 클라우드가 생성됩니다.


728x90
반응형

728x90
반응형

  이번 시간에는 플라스크(Flask) 웹 서버를 이용하여 워드 클라우드 API 를 구현하는 시간을 가져보도록 하겠습니다. 클라이언트(Client)에 해당하는 리액트 프론트 엔드에서 워드 클라우드 이미지를 요청하기 위해서는, 클라이언트의 요청(Request)을 받아서 워드 클라우드를 그려주는 웹 서버를 작업해야 합니다.


  추가적으로 필요한 라이브러리는 다음과 같습니다.


▶ WordCloud(워드 클라우드를 그리는 라이브러리), konlpy Twitter(한국어 자연어 처리 라이브러리), Counter(명사의 출현 빈도를 세는 라이브러리), Matplotlib(워드 클라우드 이미지를 실제로 그림 파일에 표현하는 목적의 라이브러리)


pip3 install wordcloud
pip3 install konlpy
pip3 install matplotlib
pip3 install flask
pip3 install flask_cors


  일단 프로젝트를 다음과 같이 구성합니다. 파이참(PyCharm)으로 열거나 혹은 단순한 폴더 형태로 구비하시면 됩니다. 폴더에는 outputs(워드 클라우드가 저장되는 폴더), NanumGothic.ttf(나눔고딕 글씨체), word_cloud.py(전체 소스코드)가 포함됩니다.


NanumGothic.ttf





▶ word_cloud.py

# 단어구름에 필요한 라이브러리를 불러옵니다.
from wordcloud import WordCloud
# 한국어 자연어 처리 라이브러리를 불러옵니다.
from konlpy.tag import Twitter
# 명사의 출현 빈도를 세는 라이브러리를 불러옵니다.
from collections import Counter
# 그래프 생성에 필요한 라이브러리를 불러옵니다.
import matplotlib.pyplot as plt
# Flask 웹 서버 구축에 필요한 라이브러리를 불러옵니다.
from flask import Flask, request, jsonify

# 플라스크 웹 서버 객체를 생성합니다.
app = Flask(__name__)

# 폰트 경로 설정
font_path = 'NanumGothic.ttf'


def get_tags(text, max_count, min_length):
# 명사만 추출합니다.
t = Twitter()
nouns = t.nouns(text)
processed = [n for n in nouns if len(n) >= min_length]
# 모든 명사의 출현 빈도를 계산합니다.
count = Counter(processed)
result = {}
# 출현 빈도가 높은 max_count 개의 명사만을 추출합니다.
for n, c in count.most_common(max_count):
result[n] = c
# 추출된 단어가 하나도 없는 경우 '내용이 없습니다.'를 화면에 보여줍니다.
if len(result) == 0:
result["내용이 없습니다."] = 1
return result


def make_cloud_image(tags, file_name):
# 만들고자 하는 워드 클라우드의 기본 설정을 진행합니다.
word_cloud = WordCloud(
font_path=font_path,
width=800,
height=800,
background_color="white",
)
# 추출된 단어 빈도수 목록을 이용해 워드 클라우드 객체를 초기화 합니다.
word_cloud = word_cloud.generate_from_frequencies(tags)
# 워드 클라우드를 이미지로 그립니다.
fig = plt.figure(figsize=(10, 10))
plt.imshow(word_cloud)
plt.axis("off")
# 만들어진 이미지 객체를 파일 형태로 저장합니다.
fig.savefig("outputs/{0}.png".format(file_name))


def process_from_text(text, max_count, min_length, words):
# 최대 max_count 개의 단어 및 등장 횟수를 추출합니다.
tags = get_tags(text, max_count, min_length)
# 단어 가중치를 적용합니다.
for n, c in words.items():
if n in tags:
tags[n] = tags[n] * int(words[n])
# 명사의 출현 빈도 정보를 통해 워드 클라우드 이미지를 생성합니다.
make_cloud_image(tags, "output")


@app.route("/process", methods=['GET', 'POST'])
def process():
content = request.json
words = {}
if content['words'] is not None:
for data in content['words'].values():
words[data['word']] = data['weight']
process_from_text(content['text'], content['maxCount'], content['minLength'], words)
result = {'result': True}
return jsonify(result)


if __name__ == '__main__':
app.run('0.0.0.0', port=5000)

  서버를 실행하면 자동으로 5000번 포트에 웹 서버 할당이 이루어집니다.



※ API 테스트 ※


  이제 만들어진 플라스크 웹 서버에 API 테스트를 진행해보도록 하겠습니다. 크롬(Chrome) 확장 프로그램인 Restlet을 이용하면 쉽게 Rest API를 테스트할 수 있습니다.


▶ 요청(Request) JSON 예제


{
    "text": "안녕하세요? 저는 한국교원대학교 나동빈입니다. 여러분들과 함께 다양한 공부를 진행하면서 스터디에 참여하고 싶어요. 한 번 공부를 할 때 제대로 공부를 하는 것이 목표입니다. 공부는 쉽지 않지만 열심히 하다 보면 재미를 느끼고 참여할 수 있을 것 같아요.",
    "maxCount": 15,
  "minLength": 2,
    "words":
{"1":{"weight":"7","word":"스터디"},"2":{"weight":"5","word":"참여"},"3":{"weight":"5","word":"분노"},"4":{"weight":"4","word":"치킨"}}
}


  이를 /process 경로로 전달하시면 됩니다.



  실행 결과 다음과 같이 성공 메시지가 출력됩니다.




  이후에 서버 폴더로 이동하여 output.png 파일을 확인해 보시면 다음과 같이 워드 클라우드 파일이 성공적으로 생성되어 있습니다.



※ 깃 허브에 업로드하기 ※


  이후에 다음과 같이 깃 허브에 리포지터리를 업로드 하시면 됩니다.



728x90
반응형

728x90
반응형

1. Style 적용을 위한 라이브러리 추가하기


  yarn add style-loader

  yarn add css-loader


2. 웹팩(Webpack) conf 파일 수정하기


'use strict'
const path = require('path');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
entry: {
main: ['./src/main.js']
},
output: {
path: path.resolve(__dirname, './build'),
filename: '[name].js'
},
module: {
rules: [{
test: /\.js$/,
include: path.resolve(__dirname, './src'),
loaders: 'babel-loader'
}, {
test: /\.css$/,
loader: "style-loader!css-loader"
}]
},
plugins: [
new CopyWebpackPlugin([{
context: './public',
from: '*.*'
}]),
],
devServer: {
contentBase: './public',
host: 'localhost',
port: 8080
}
}


▶ ./src/index.css


@import url(https://fonts.googleapis.com/earlyaccess/notosanskr.css);

div {
font-family: 'Noto Sans KR' !important;
}


▶ ./src/main.js


import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './components/App';
import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';

const theme = createMuiTheme({
typography: {
useNextVariants: true,
fontFamily: '"Noto Sans KR"'
}
});

ReactDOM.render(<MuiThemeProvider theme={theme}><App /></MuiThemeProvider>, document.getElementById('app'));


  이후에 모든 컴포넌트 JS 파일에 import '../index.css';를 넣어주시면 됩니다.


※ 실행 결과 ※



728x90
반응형

728x90
반응형

1. 텍스트 가지치기 모듈 다운로드


  yarn add react-text-truncate


  (설치가 안 된다면, 개발 환경을 관리자 권한으로 실행해보세요.)



2. 소스코드 작성하기


▶ ./src/components/Texts.js


import TextTruncate from 'react-text-truncate';
import React from 'react';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import { withStyles } from '@material-ui/core/styles';
import Grid from '@material-ui/core/Grid';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import Fab from '@material-ui/core/Fab';
import AddIcon from '@material-ui/icons/Add';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import TextField from '@material-ui/core/TextField';
import { Link as RouterLink } from 'react-router-dom';
import Link from '@material-ui/core/Link';

const styles = theme => ({
hidden: {
display: 'none'
},
fab: {
position: 'fixed',
bottom: '20px',
right: '20px'
},
});

const databaseURL = "https://react-example-55161.firebaseio.com/";

class Texts extends React.Component {

constructor(props) {
super(props);
this.state = {
fileName: '',
fileContent: null,
texts: '',
textName: '',
dialog: false
}
}

_get() {
fetch(`${databaseURL}/texts.json`).then(res => {
if(res.status != 200) {
throw new Error(res.statusText);
}
return res.json();
}).then(texts => this.setState({texts: (texts == null) ? {} : texts}));
}

_post(text) {
return fetch(`${databaseURL}/texts.json`, {
method: 'POST',
body: JSON.stringify(text)
}).then(res => {
if(res.status != 200) {
throw new Error(res.statusText);
}
return res.json();
}).then(data => {
let nextState = this.state.texts;
nextState[data.name] = text;
this.setState({texts: nextState});
});
}

_delete(id) {
return fetch(`${databaseURL}/texts/${id}.json`, {
method: 'DELETE'
}).then(res => {
if(res.status != 200) {
throw new Error(res.statusText);
}
return res.json();
}).then(() => {
let nextState = this.state.texts;
delete nextState[id];
this.setState({texts: nextState});
});
}

componentDidMount() {
this._get();
}

handleDialogToggle = () => this.setState({
dialog: !this.state.dialog,
fileName: '',
fileContent: '',
textName: ''
})

handleValueChange = (e) => {
let nextState = {};
nextState[e.target.name] = e.target.value;
this.setState(nextState);
}

handleSubmit = () => {
const text = {
textName: this.state.textName,
textContent: this.state.fileContent
}
this.handleDialogToggle();
if (!text.textName || !text.textContent) {
return;
}
this._post(text);
}

handleDelete = (id) => {
this._delete(id);
}

handleFileChange = (e) => {
let reader = new FileReader();
reader.onload = () => {
let text = reader.result;
this.setState({
fileContent: text
})
}
reader.readAsText(e.target.files[0], "EUC-KR");
this.setState({
fileName: e.target.value
});
}

render() {
const { classes } = this.props;
return (
<div>
{Object.keys(this.state.texts).map(id => {
const text = this.state.texts[id];
return (
<Card key={id}>
<CardContent>
<Typography color="textSecondary" gutterBottom>
내용: {text.textContent.substring(0, 24) + '...'}
</Typography>
<Grid container>
<Grid item xs={6}>
<Typography variant="h5" component="h2">
{text.textName.substring(0, 14) + '...'}
</Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="h5" component="h2">
<Link component={RouterLink} to={"detail/" + id}>
<Button variant="contained" color="primary">보기</Button>
</Link>
</Typography>
</Grid>
<Grid item xs={3}>
<Button variant="contained" color="primary" onClick={() => this.handleDelete(id)}>삭제</Button>
</Grid>
</Grid>
</CardContent>
</Card>
);
})}
<Fab color="primary" className={classes.fab} onClick={this.handleDialogToggle}>
<AddIcon />
</Fab>
<Dialog open={this.state.dialog} onClose={this.handleDialogToggle}>
<DialogTitle>텍스트 추가</DialogTitle>
<DialogContent>
<TextField label="텍스트 이름" type="text" name="textName" value={this.state.textName} onChange={this.handleValueChange} /><br /><br />
<input className={classes.hidden} accept="text/plain" id="raised-button-file" type="file" file={this.state.file} value={this.state.fileName} onChange={this.handleFileChange} />
<label htmlFor="raised-button-file">
<Button variant="contained" color="primary" component="span" name="file">
{this.state.fileName === ''? ".txt 파일 선택" : this.state.fileName}
</Button>
</label>
<TextTruncate
line={1}
truncateText="..."
text={this.state.fileContent}
/>
</DialogContent>
<DialogActions>
<Button variant="contained" color="primary" onClick={this.handleSubmit}>추가</Button>
<Button variant="outlined" color="primary" onClick={this.handleDialogToggle}>닫기</Button>
</DialogActions>
</Dialog>
</div>
);
}
}

export default withStyles(styles)(Texts);


▶ ./src/components/Detail.js


import React from 'react';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';

class Detail extends React.Component {
constructor(props) {
super(props);
}
render() {
return (
<Card>
<CardContent>
{this.props.match.params.textID}
</CardContent>
</Card>
);
}
}

export default Detail;


▶ ./src/components/App.js


import React from 'react';
import { HashRouter as Router, Route } from 'react-router-dom';
import AppShell from './AppShell';
import Home from './Home';
import Texts from './Texts';
import Words from './Words';
import Detail from './Detail';

class App extends React.Component {
render() {
return (
<Router>
<AppShell>
<div>
<Route exact path="/" component={Home}/>
<Route exact path="/texts" component={Texts}/>
<Route exact path="/words" component={Words}/>
<Route exact path="/detail/:textID" component={Detail}/>
</div>
</AppShell>
</Router>
);
}
}

export default App;


※ 실행 결과 ※


  텍스트 페이지에서 텍스트 파일을 올려서 내용을 확인할 수 있습니다.





  [보기] 버튼을 누르면 상세 페이지로 이동됩니다. 현재는 ID 값만 보내도록 처리했습니다.



728x90
반응형

728x90
반응형

1. 빌드(Build)를 위한 웹팩 설정



'use strict'
const path = require('path');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
entry: {
main: ['./src/main.js']
},
output: {
path: path.resolve(__dirname, './build'),
filename: '[name].js'
},
module: {
rules: [{
test: /\.js$/,
include: path.resolve(__dirname, './src'),
loaders: 'babel-loader'
}]
},
plugins: [
new CopyWebpackPlugin([{
context: './public',
from: '*.*'
}]),
],
devServer: {
contentBase: './public',
host: 'localhost',
port: 8080
}
}


2. 각종 모듈 재설치


  (필요할 시 수행하세요.)




3. 파이어베이스(Firebase) 패키지 설치 및 설정하기


  yarn add firebase-tools 명령으로 설치할 수 있습니다.



  이 때 firebase init 명령이 동작하지 않으면, 관리자 권한으로 에디터를 실행하세요. 또한 firebase 계정을 변경하고 싶을 때는 firebase logout 및 firebase login 명령을 이용하시면 됩니다.





4. 기존의 build 폴더를 삭제한 뒤에 yarn build으로 재빌드하기



5. 파이어베이스 호스팅 서비스를 이용해 배포하기



6. .gitignore 파일 수정 및 푸시(Push)하기


node_modules
build
.firebase/
firebase.json
.firebaserc


※ 배포 완료 ※



728x90
반응형

728x90
반응형

1. 지난 소스코드 복습하기


2. 소스코드 수정하기


▶ AppShell.js


<MenuItem onClick={this.handleDrawerToggle}>
<Link component={RouterLink} to="/">
홈 화면
</Link>
</MenuItem>
<MenuItem onClick={this.handleDrawerToggle}>
<Link component={RouterLink} to="/texts">
텍스트 관리
</Link>
</MenuItem>
<MenuItem onClick={this.handleDrawerToggle}>
<Link component={RouterLink} to="/words">
단어 관리
</Link>
</MenuItem>


▶ Words.js


import React from 'react';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import Typography from '@material-ui/core/Typography';
import { withStyles } from '@material-ui/core/styles';
import Grid from '@material-ui/core/Grid';
import Button from '@material-ui/core/Button';
import Fab from '@material-ui/core/Fab';
import AddIcon from '@material-ui/icons/Add';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import TextField from '@material-ui/core/TextField';

const styles = theme => ({
fab: {
position: 'fixed',
bottom: '20px',
right: '20px'
},
});

const databaseURL = "https://react-example-55161.firebaseio.com/";

class Words extends React.Component {
constructor() {
super();
this.state = {
words: {},
dialog: false,
word: '',
weight: ''
};
}

_get() {
fetch(`${databaseURL}/words.json`).then(res => {
if(res.status != 200) {
throw new Error(res.statusText);
}
return res.json();
}).then(words => this.setState({words: words}));
}

_post(word) {
return fetch(`${databaseURL}/words.json`, {
method: 'POST',
body: JSON.stringify(word)
}).then(res => {
if(res.status != 200) {
throw new Error(res.statusText);
}
return res.json();
}).then(data => {
let nextState = this.state.words;
nextState[data.name] = word;
this.setState({words: nextState});
});
}

_delete(id) {
return fetch(`${databaseURL}/words/${id}.json`, {
method: 'DELETE'
}).then(res => {
if(res.status != 200) {
throw new Error(res.statusText);
}
return res.json();
}).then(() => {
let nextState = this.state.words;
delete nextState[id];
this.setState({words: nextState});
});
}

componentDidMount() {
this._get();
}

handleDialogToggle = () => this.setState({
dialog: !this.state.dialog
})

handleValueChange = (e) => {
let nextState = {};
nextState[e.target.name] = e.target.value;
this.setState(nextState);
}

handleSubmit = () => {
const word = {
word: this.state.word,
weight: this.state.weight
}
this.handleDialogToggle();
if (!word.word && !word.weight) {
return;
}
this._post(word);
}

handleDelete = (id) => {
this._delete(id);
}

render() {
const { classes } = this.props;
return (
<div>
{Object.keys(this.state.words).map(id => {
const word = this.state.words[id];
return (
<div key={id}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
가중치: {word.weight}
</Typography>
<Grid container>
<Grid item xs={6}>
<Typography variant="h5" component="h2">
{word.word}
</Typography>
</Grid>
<Grid item xs={6}>
<Button variant="contained" color="primary" onClick={() => this.handleDelete(id)}>삭제</Button>
</Grid>
</Grid>
</CardContent>
</Card>
<br />
</div>
);
})}
<Fab color="primary" className={classes.fab} onClick={this.handleDialogToggle}>
<AddIcon />
</Fab>
<Dialog open={this.state.dialog} onClose={this.handleDialogToggle}>
<DialogTitle>단어 추가</DialogTitle>
<DialogContent>
<TextField label="단어" type="text" name="word" value={this.state.word} onChange={this.handleValueChange}/><br/>
<TextField label="가중치" type="number" name="weight" value={this.state.weight} onChange={this.handleValueChange}/><br/>
</DialogContent>
<DialogActions>
<Button variant="contained" color="primary" onClick={this.handleSubmit}>추가</Button>
<Button variant="outlined" color="primary" onClick={this.handleDialogToggle}>닫기</Button>
</DialogActions>
</Dialog>
</div>
);
}
}

export default withStyles(styles)(Words);


※ 실행 결과 ※





728x90
반응형